on 07-14-2026 06:25 AM
Applies to: Cisco Unified Communications Manager (CUCM) 12.5, 14, and 15 Audience: Voice and UC engineers with basic Python experience Topics: AXL · Bulk Updates · Reporting · Validation · Change Tracking
⚠️ Before you run any of this against production
Every code sample below is a working excerpt from the reference implementation in §16 — but three things in this article are version-dependent and must be confirmed against your own cluster before use:
Item Why it varies How to confirm
zeep object shapes (_value_1, resp["return"]["phone"]) Depends on the AXL schema version your WSDL was generated from Print a getPhone response in a REPL and inspect it SQL table/column names in §7.2 The database schema changes between CUCM releases Check the Data Dictionary for your exact release AXL fault message text in §8.4 Wording differs by release Trigger each fault once in a lab and log the real string The code is syntax-checked and internally consistent. It has not been executed against a live cluster — that step is yours, and it belongs in a lab, not a change window. This article is a pattern, not a drop-in binary.
Cisco Unified Communications Manager administration is usually handled through the GUI, the Bulk Administration Tool, and standard operational reports. Those tools work. But in large enterprise voice environments, a category of work becomes repetitive: validating device settings, updating descriptions after a site move, checking line configuration, reviewing user-to-device associations, exporting phone inventory, and confirming that a change actually applied.
Python can automate this work in a controlled, repeatable way. The goal is not to replace Cisco Unified CM Administration, BAT, or change control. The goal is to reduce manual effort, improve consistency, and create a repeatable method for reporting, validation, and change tracking.
The interface is the Administrative XML Web Service (AXL) — Cisco's XML/SOAP provisioning API for inserting, retrieving, updating, and removing configuration data in the Unified CM configuration database.
This article walks the full workflow: connect → read → validate → dry-run → apply → verify → record.
Manual administration is fine for one device, one user, one directory number. The problem appears when the same task repeats across hundreds or thousands of objects. At that scale, the primary risk is no longer time — it is inconsistency.
Typical candidates:
In each case, Python earns its place by doing what the GUI cannot: read the current state, compare it against a standard, decide per object, apply only the changes that are needed, and prove what it did.
Unified CM already ships with the Bulk Administration Tool. BAT performs bulk transactions against the Unified CM database and automates add, update, delete, and export operations that would otherwise be done one at a time. For a straightforward batch of uniform changes, BAT is often the right answer and it is fully supported.
Use BAT when:
Use Python with AXL when:
The difference in one example. BAT can push a new common value onto a large group of phones. Python is the better tool when the script must first check each phone, confirm its current device pool, verify the target value is valid for that site, update only the devices that actually differ, skip the exceptions, and produce a before-and-after report.
Rule of thumb: BAT for supported bulk operations. Python when the change requires decision-making, validation, or integration with another system.
On the Publisher: Cisco Unified Serviceability → Tools → Service Activation → activate Cisco AXL Web Service.
Publisher only for writes. AXL write operations must target the Publisher. Subscribers accept read requests, but provisioning belongs on the Pub. Point automation at the Publisher FQDN.
Do not automate with a named human account or with administrator. Create an Application User (e.g. axl-automation) and assign the minimum roles required:
Reporting-only jobs get a separate, read-only account. Least privilege applies here as it does everywhere else.
Cisco Unified CM Administration → Application → Plugins → download the AXL Toolkit. Extract the schema into a version-pinned directory:
schema/
14.0/
AXLAPI.wsdl
AXLEnums.xsd
AXLSoap.xsdThe WSDL must match your cluster's major version. A 12.5 schema pointed at a 14 cluster will mostly work — the worst kind of failure, because it silently omits fields introduced in the newer release. Cisco's guidance is to adopt the latest available AXL schema and review schema changes when moving between versions. Re-test all automation after every CUCM upgrade.
Export the Tomcat certificate from OS Administration → Security → Certificate Management and reference it as a CA bundle. Disabling TLS verification is acceptable in a lab and unacceptable in production — you are sending administrative credentials over that socket.
pip install zeep requests pydantic
zeep is the SOAP client. Parsing the AXL WSDL is slow, so cache it.
# src/axl_client.py
import os
from pathlib import Path
from requests import Session
from requests.auth import HTTPBasicAuth
from zeep import Client, Settings
from zeep.cache import SqliteCache
from zeep.transports import Transport
BINDING = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"
def get_axl(host: str, username: str, password: str,
wsdl_dir: str = "schema/14.0",
ca_bundle: str | bool = True,
timeout: int = 30):
"""Return an AXL service proxy bound to the CUCM Publisher."""
wsdl = Path(wsdl_dir, "AXLAPI.wsdl").absolute().as_uri()
session = Session()
session.auth = HTTPBasicAuth(username, password)
session.verify = ca_bundle # Tomcat CA cert. Never False in prod.
transport = Transport(session=session, cache=SqliteCache(), timeout=timeout)
settings = Settings(strict=False, xml_huge_tree=True)
client = Client(wsdl, settings=settings, transport=transport)
return client.create_service(BINDING, f"https://{host}:8443/axl/")
def get_axl_from_env():
"""Credentials come from the environment, never from code."""
return get_axl(
host=os.environ["CUCM_PUB"],
username=os.environ["AXL_USER"],
password=os.environ["AXL_PASS"],
wsdl_dir=os.environ.get("AXL_WSDL_DIR", "schema/14.0"),
ca_bundle=os.environ.get("CUCM_CA_BUNDLE", True),
)
def smoke_test(axl) -> str:
"""Confirm connectivity, credentials, roles, and TLS trust in one call."""
return axl.getCCMVersion()["return"]["componentVersion"]["version"]Run the smoke test first:
>>> from axl_client import get_axl_from_env, smoke_test >>> smoke_test(get_axl_from_env()) '14.0.1.13900-155'
If that returns a version string, you have a working client, valid credentials, correct roles, and a trusted certificate. If it fails, you have exactly one of those four problems — check them in that order.
Every script below follows the same shape. Nothing goes straight from a CSV to an update.
Input file (the intent) │ approved CSV / Excel / ServiceNow export ▼ Input validation ......... Is the request even coherent? → abort on error ▼ Pre-flight validation .... Do the referenced objects exist? → abort on error ▼ Plan (diff) .............. What actually differs? Skip the rest. ▼ Dry run .................. Print the plan. Change nothing. ← DEFAULT ▼ Rollback file ............ Capture every OLD value first. ▼ Apply .................... Per device, isolating failures. ▼ Verify ................... Re-read. A 200 is not proof. ▼ Record ................... Audit log + snapshot diff.
A script that only performs updates is dangerous. A script that validates, updates, and validates again is operationally useful.
Two small types carry the whole workflow: an intent (what we want) and a change (what differs).
# src/models.py
import re
from dataclasses import dataclass
from pydantic import BaseModel, field_validator
# CSV column -> AXL element name
FIELD_MAP = {
"description": "description",
"device_pool": "devicePoolName",
"css": "callingSearchSpaceName",
}
# Ask AXL only for the fields we actually use.
RETURNED_TAGS = {
"name": "",
"description": "",
"devicePoolName": "",
"callingSearchSpaceName": "",
}
DEVICE_NAME_RE = re.compile(r"SEP[0-9A-F]{12}")
class PhoneIntent(BaseModel):
"""One row of the approved input file."""
device: str
description: str
device_pool: str
css: str
@field_validator("device")
@classmethod
def valid_device_name(cls, v: str) -> str:
v = v.strip().upper()
if not DEVICE_NAME_RE.fullmatch(v):
raise ValueError(f"{v!r} is not a valid SEP device name")
return v
@dataclass
class Change:
"""A single field on a single object that needs to change."""
device: str
field: str # AXL element name, e.g. 'devicePoolName'
old: str | None
new: strThe input file is the intent. The script's only job is to make CUCM match it.
device,description,device_pool,css SEP001122334455,DAL - Sales - 512345 - jsmith,SiteA-DP,SiteA_Internal SEP00AABBCCDDEE,DAL - Sales - 512346 - mjones,SiteA-DP,SiteA_Internal
Choosing wrong here is the most common cause of "my script is slow and the Publisher CPU is pinned."
Three rules for every list call:
# src/inventory.py
from models import RETURNED_TAGS
def axl_text(value):
"""Unwrap an AXL element to a plain string.
zeep renders many AXL elements as objects carrying the text in
`_value_1` (because they also carry a `uuid` attribute), while others
come back as plain strings. The exact shape varies by schema version,
so never index `._value_1` directly -- go through this helper.
"""
if value is None:
return None
return getattr(value, "_value_1", value)
def list_all(method, criteria: dict, tags: dict, page: int = 500):
"""Yield every object matching `criteria`, one page at a time."""
skip = 0
while True:
resp = method(searchCriteria=criteria, returnedTags=tags,
first=page, skip=skip)
rows = resp["return"]
if not rows:
return
batch = rows[next(iter(rows))] # 'phone', 'line', 'user', ...
for item in batch:
yield item
if len(batch) < page:
return
skip += page
def current_values(phone) -> dict:
"""Current AXL values for the fields this tool manages."""
return {
"description": axl_text(phone.description),
"devicePoolName": axl_text(phone.devicePoolName),
"callingSearchSpaceName": axl_text(phone.callingSearchSpaceName),
}
def phone_inventory(axl, name_filter: str = "SEP%") -> list[dict]:
"""Flat phone inventory, suitable for CSV export or a snapshot."""
rows = []
for p in list_all(axl.listPhone, {"name": name_filter}, RETURNED_TAGS):
values = current_values(p)
rows.append({
"device": axl_text(p.name),
"description": values["description"],
"device_pool": values["devicePoolName"],
"css": values["callingSearchSpaceName"],
})
return sorted(rows, key=lambda r: r["device"])Note the axl_text() helper. Do not sprinkle ._value_1 through your code. Whether a given element arrives as an object or a bare string depends on the schema version — funnel every read through one place so an upgrade breaks one function instead of forty.
For cluster-wide reporting that would otherwise take many list calls:
# src/inventory.py (continued)
def sql(axl, query: str) -> list[dict]:
"""Thick AXL. Read-only. Verify the schema against YOUR release."""
resp = axl.executeSQLQuery(sql=query)
rows = resp["return"]
if not rows:
return []
return [{col.tag: col.text for col in row} for row in rows["row"]]-- Phones by model and device pool -- ⚠ VERIFY against the Data Dictionary for YOUR CUCM release before use. SELECT d.name, d.description, tm.name AS model, dp.name AS devicepool FROM device d JOIN typemodel tm ON d.tkmodel = tm.enum JOIN devicepool dp ON d.fkdevicepool = dp.pkid WHERE d.tkclass = 1
Three constraints, all of which matter:
This one trips up almost everyone. Registration state lives in RIS, not the CUCM configuration database. AXL tells you a phone exists and how it is configured. It will not tell you whether it is registered.
For "which phones are unregistered," use the RisPort70 service (selectCmDevice) and join the result to your AXL inventory. Keep the two data sources clearly separated in your reporting code.
Read-only automation is the safest and often the most valuable. Before automating changes, automate visibility:
Cisco also provides CDR and CAR for call activity, QoS, traffic, user call volume, billing, and gateway reporting. Joining AXL configuration data to CDR/CMR, CUIC, ticketing, or asset inventory is usually where the real reporting value lives.
Schedule the reporting jobs. Nobody should be building a phone inventory by hand.
Validation is what makes automation acceptable in an enterprise. It happens in three places, and skipping any one of them is how you find out why it was there.
# src/validators.py
import csv
from pydantic import ValidationError
from zeep.exceptions import Fault
from inventory import axl_text, current_values
from models import FIELD_MAP, Change, PhoneIntent
def load_intents(path: str) -> tuple[list[PhoneIntent], list[str]]:
"""Parse and schema-validate the approved input file."""
intents, errors = [], []
with open(path, newline="") as f:
for line_no, row in enumerate(csv.DictReader(f), start=2):
try:
intents.append(PhoneIntent(**row))
except ValidationError as e:
errors.append(f"{path}:{line_no}: {e.errors()[0]['msg']}")
return intents, errors
def existing_names(list_method, key: str) -> set[str]:
"""All configured names for a reference object (device pool, CSS, ...)."""
resp = list_method(searchCriteria={"name": "%"}, returnedTags={"name": ""})
if not resp["return"]:
return set()
return {axl_text(row.name) for row in resp["return"][key]}
def preflight(axl, intents: list[PhoneIntent]) -> list[str]:
"""Confirm every referenced object exists BEFORE touching a device."""
valid_pools = existing_names(axl.listDevicePool, "devicePool")
valid_css = existing_names(axl.listCss, "css")
errors = []
for intent in intents:
if intent.device_pool not in valid_pools:
errors.append(
f"{intent.device}: unknown device pool {intent.device_pool!r}")
if intent.css not in valid_css:
errors.append(f"{intent.device}: unknown CSS {intent.css!r}")
return errors
def fetch_phone(axl, device: str):
"""Return the phone, or None if it does not exist."""
try:
return axl.getPhone(name=device)["return"]["phone"]
except Fault:
return None
def diff_phone(intent: PhoneIntent, phone) -> list[Change]:
"""Fields where CUCM does not match the intent. Empty list == no work."""
current = current_values(phone)
changes = []
for csv_field, axl_field in FIELD_MAP.items():
wanted = getattr(intent, csv_field)
if wanted and wanted != current[axl_field]:
changes.append(
Change(intent.device, axl_field, current[axl_field], wanted))
return changes
def verify(axl, device: str, expected: dict) -> list[str]:
"""Re-read the object. A successful AXL response is not proof."""
phone = fetch_phone(axl, device)
if phone is None:
return [f"{device}: disappeared after update"]
actual = current_values(phone)
return [
f"{device}.{field}: expected {value!r}, got {actual.get(field)!r}"
for field, value in expected.items()
if actual.get(field) != value
]Three points worth pulling out:
CUCM's audit logs will tell you that axl-automation changed something at 02:14. They will not tell you which ticket, which engineer, or what the value was before. That context comes from your tooling.
# src/change_tracker.py
import csv
import getpass
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from inventory import phone_inventory
from models import Change
class ChangeTracker:
"""One instance per run. Writes an append-only audit record."""
def __init__(self, ticket: str, input_file: str, cluster: str,
outdir: str = "output"):
self.run_id = str(uuid.uuid4())
self.ticket = ticket
self.input_file = input_file
self.cluster = cluster
self.engineer = getpass.getuser()
self.outdir = Path(outdir)
self.outdir.mkdir(parents=True, exist_ok=True)
self.log_path = self.outdir / "change_log.jsonl"
self.rollback_path = self.outdir / f"rollback_{self.run_id}.csv"
def write_rollback(self, changes: list[Change]) -> Path:
"""Capture every OLD value BEFORE anything is applied."""
with open(self.rollback_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["device", "field", "restore_to"])
for c in changes:
w.writerow([c.device, c.field, c.old])
return self.rollback_path
def record(self, change: Change, result: str, error: str | None = None):
"""One line per field touched. Append-only."""
entry = {
"run_id": self.run_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"engineer": self.engineer,
"ticket": self.ticket,
"cluster": self.cluster,
"input_file": self.input_file,
"object_type": "Phone",
"object_name": change.device,
"field": change.field,
"old_value": change.old,
"new_value": change.new,
"result": result,
"error": error,
}
with open(self.log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
def snapshot(axl, path: str = "state/phones.json"):
"""Normalized, sorted config dump. Commit before and after the run;
`git diff` on this file is the cleanest change report you will get."""
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
data = phone_inventory(axl)
with open(out, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
return outThe rollback file is written before the first write, not after the incident. The diff already captured every old value; persisting it costs nothing and is the difference between a reversible change and a gamble.
Snapshot diffs give you a change report for free:
python -m src.change_tracker --out state/phones.json
git commit -am "pre-change snapshot: CHG0041982"
python -m src.main --input-file input/approved_changes.csv \
--ticket-number CHG0041982 --apply
python -m src.change_tracker --out state/phones.json
git diff state/phones.json # <-- this is your change recordA git diff on a sorted, normalized snapshot is the cleanest change report you will ever produce for a UC cluster. It doubles as a disaster-recovery artifact.
This is the orchestrator. Every fragment above is imported here — nothing is left dangling.
# src/main.py
"""Bulk phone update: read -> validate -> dry run -> apply -> verify -> record.
Dry run is the DEFAULT. Writing requires an explicit --apply.
"""
import argparse
import os
import sys
import time
from collections import defaultdict
from zeep.exceptions import Fault
from axl_client import get_axl_from_env, smoke_test
from change_tracker import ChangeTracker
from models import Change
from validators import diff_phone, fetch_phone, load_intents, preflight, verify
WRITE_DELAY_SECONDS = 0.2 # be kind to the Publisher
def parse_args():
p = argparse.ArgumentParser(description="Bulk-update CUCM phones via AXL.")
p.add_argument("--input-file", required=True)
p.add_argument("--ticket-number", required=True)
p.add_argument("--apply", action="store_true",
help="write to CUCM (default: dry run)")
p.add_argument("--limit", type=int, default=None,
help="process at most N devices")
p.add_argument("--site", default=None,
help="only devices whose description starts with this site code")
return p.parse_args()
def plan(axl, intents) -> tuple[list[Change], list[str]]:
"""Build the change list. Returns (changes, skipped_reasons)."""
changes: list[Change] = []
skipped: list[str] = []
for intent in intents:
phone = fetch_phone(axl, intent.device)
if phone is None:
skipped.append(f"{intent.device}: not found in CUCM")
continue
device_changes = diff_phone(intent, phone)
if not device_changes:
skipped.append(f"{intent.device}: already correct")
continue
changes.extend(device_changes)
return changes, skipped
def print_plan(changes: list[Change], skipped: list[str]):
print(f"{'Device':<18} {'Field':<24} {'Current':<24} {'Target':<24}")
print("-" * 92)
for c in changes:
print(f"{c.device:<18} {c.field:<24} {str(c.old):<24} {c.new:<24}")
for s in skipped:
print(f"SKIPPED {s}")
print("-" * 92)
print(f"{len(changes)} change(s) across "
f"{len({c.device for c in changes})} device(s); {len(skipped)} skipped.")
def apply_changes(axl, changes: list[Change], tracker: ChangeTracker) -> int:
"""Apply per device, isolating failures. Returns the failure count."""
grouped: dict[str, list[Change]] = defaultdict(list)
for c in changes:
grouped[c.device].append(c)
failures = 0
for device, device_changes in grouped.items():
payload = {c.field: c.new for c in device_changes}
try:
axl.updatePhone(name=device, **payload)
except Fault as e:
failures += 1
for c in device_changes:
tracker.record(c, result="failed", error=str(e))
continue # one bad device must not abort the run
problems = verify(axl, device, payload)
if problems:
failures += 1
for c in device_changes:
tracker.record(c, result="unverified", error="; ".join(problems))
else:
for c in device_changes:
tracker.record(c, result="updated")
time.sleep(WRITE_DELAY_SECONDS)
return failures
def main() -> int:
args = parse_args()
axl = get_axl_from_env()
print(f"Connected to {os.environ['CUCM_PUB']} (CUCM {smoke_test(axl)})")
# 1. Input validation -- is the request even coherent?
intents, input_errors = load_intents(args.input_file)
if input_errors:
for e in input_errors:
print("INPUT ERROR:", e, file=sys.stderr)
return 1
if args.site:
intents = [i for i in intents if i.description.startswith(args.site)]
if args.limit:
intents = intents[:args.limit]
# 2. Pre-flight -- do the referenced objects exist? Fail the WHOLE run.
for e in preflight(axl, intents):
print("VALIDATION ERROR:", e, file=sys.stderr)
input_errors.append(e)
if input_errors:
print("Aborting. No changes were made.", file=sys.stderr)
return 1
# 3. Plan -- diff intent against reality.
changes, skipped = plan(axl, intents)
print_plan(changes, skipped)
if not changes:
print("Nothing to do.")
return 0
# 4. Dry run is the default.
if not args.apply:
print("\nDRY RUN -- nothing applied. Re-run with --apply.")
return 0
# 5. Rollback file is written BEFORE the first write.
tracker = ChangeTracker(ticket=args.ticket_number,
input_file=args.input_file,
cluster=os.environ["CUCM_PUB"])
print(f"Rollback file: {tracker.write_rollback(changes)}")
print(f"Run ID: {tracker.run_id}")
# 6. Apply + verify + record.
failures = apply_changes(axl, changes, tracker)
print(f"\nDone. {len(changes) - failures} change(s) applied, "
f"{failures} device(s) failed or unverified.")
print(f"Audit log: {tracker.log_path}")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())$ export CUCM_PUB=cucm-pub.example.com AXL_USER=axl-automation
$ export AXL_PASS='...' CUCM_CA_BUNDLE=certs/tomcat.pem
$ python -m src.main --input-file input/approved_changes.csv \
--ticket-number CHG0041982
Connected to cucm-pub.example.com (CUCM 14.0.1.13900-155)
Device Field Current Target
--------------------------------------------------------------------------------------------
SEP00AABBCCDDEE callingSearchSpaceName SiteA_Old SiteA_Internal
SEP00AABBCCDDEE description DAL - Sales - 512346 DAL - Sales - 512346 - mjones
SKIPPED SEP001122334455: already correct
SKIPPED SEP00FFEEDDCCBB: not found in CUCM
--------------------------------------------------------------------------------------------
2 change(s) across 1 device(s); 2 skipped.
DRY RUN -- nothing applied. Re-run with --apply.--apply is the explicit, deliberate flag. A script that writes by default will eventually be run by someone who believed it was read-only.
except Fault as e:
tracker.record(c, result="failed", error=str(e))
continue # one bad device must not abort the runCommon AXL faults:
Fault Usual cause| Item not valid: The specified {item} was not found in the database | Typo in a device pool, CSS, or device name |
| Could not insert new row - duplicate value | Object already exists |
| User not authorized | Application user missing the AXL role |
| 503 Service Unavailable | Write throttling — back off and retry |
| Timeouts during a large job | You are hammering the Publisher. Slow down. |
⚠ The exact fault strings vary by release. Trigger each condition once in a lab, log the real message, and match on the AXL error code rather than the text where you can.
AXL is a provisioning interface, not a real-time monitoring interface. Cisco states that AXL is not a real-time API and that too many requests in quick succession may be throttled. Write requests can be throttled dynamically based on the Unified CM database transaction queue, and AXL may return 503 Service Unavailable when the queue is too large — applications should retry only after a reasonable delay.
Guardrails:
Descriptions drift as devices are moved, reassigned, and renamed by hand. Standardizing them exercises the whole model.
Target format: <SiteCode> - <Department> - <Extension> - <UserID>
The input file expresses that format; main.py does the rest unchanged.
Device Name Old Description New Description Result SEP001122334455 John Desk Phone DAL - Sales - 512345 - jsmith Updated SEP00AABBCCDDEE DAL - Sales - 512346 DAL - Sales - 512346 - mjones Updated SEP00FFEEDDCCBB HR Lobby HR Lobby Skipped - Excluded
Simple, but it demonstrates the full loop: read, validate, diff, apply, verify, log.
A read-only report — and one of the highest-value scripts you can write.
Logic: if a phone belongs to Site A (by name pattern or DN range), its expected device pool is SiteA-DP. If it is assigned elsewhere, flag it. Do not auto-remediate. Report only, pending approval.
Device Name DN Current Device Pool Expected Device Pool Status SEP001122334455 512345 SiteA-DP SiteA-DP OK SEP00AABBCCDDEE 512346 Default-DP SiteA-DP Mismatch SEP00FFEEDDCCBB 612345 SiteB-DP SiteB-DP OK
Device pool drift sits upstream of routing, CSS, media resource, and SRST behavior. Catching it in a weekly report is considerably cheaper than catching it during an outage.
cucm-automation/ │ ├── schema/14.0/ # AXL WSDL, pinned to cluster version ├── config/settings.example.yml ├── input/approved_changes.csv # the intent ├── state/phones.json # committed snapshots -- git is the history ├── output/ │ ├── change_log.jsonl │ └── rollback_<run_id>.csv ├── logs/automation.log │ ├── src/ │ ├── axl_client.py # connection + auth (§4) │ ├── models.py # PhoneIntent, Change (§6) │ ├── inventory.py # list_all, sql, snapshots (§7) │ ├── validators.py # input + pre-flight + diff (§8) │ ├── change_tracker.py # audit log, rollback (§9) │ └── main.py # orchestrator (§10) │ ├── tests/ # run against a lab cluster, never prod └── README.md
Credentials are never stored in code. Use environment variables, a secrets manager, or another approved internal method.
Before this pattern touches a production cluster, confirm each item in a lab, on your CUCM version:
Automation should make Cisco voice administration safer, not merely faster at the expense of control. The best script is not the one that updates the most objects. It is the one that updates only the correct objects, skips the wrong ones, explains every decision, and leaves a clear audit trail.
Python automation improves Cisco voice administration when it is built around AXL, validation, reporting, and change tracking. AXL provides controlled configuration access. BAT remains the right tool for standard bulk operations. Python fills the gap where logic, verification, and integration are required.
The value is not in the update itself. It is in the full workflow: pre-check, approved input, controlled change, post-check, reporting, and rollback readiness.
Make the intent data. Make the change reversible. Make the record automatic.
Everything else is just SOAP.
Sample Python automation scripts are available here:
https://github.com/ahmadalkayyali/cisco-cucm-axl-python-toolkit
Pull the copy that matches your release — these documents are version-specific.
Outstanding article!
Thank you so much, Roger. I really appreciate it. I’ve been collecting real world Cisco voice notes for a long time, and I’m finally turning them into articles that can hopefully help other engineers. Your feedback means a lot, especially coming from a Cisco Community VIP.
Find answers to your questions by entering keywords or phrases in the Search bar above. New here? Use these resources to familiarize yourself with the community: