Automating Genesys Cloud Skill Configuration with the Python SDK
What You Will Build
A Python utility that parses JSON skill definitions, creates or updates routing skills, maps them to routing profiles and user targets, validates dependencies, normalizes slugs to prevent naming conflicts, generates impact reports, and exposes a local search API. This tutorial uses the genesys-cloud-python SDK with real /api/v2/routing/skills and /api/v2/routing/profiles endpoints. The code is written in Python 3.10+.
Prerequisites
- OAuth client type: Service account (client credentials grant)
- Required scopes:
routing:skill:write,routing:skill:read,routing:profile:write,routing:profile:read,routing:user:write,user:read - SDK version:
genesys-cloud-pythonv2.200.0+ - Runtime: Python 3.10+
- External dependencies:
genesys-cloud-python,httpx,fastapi,uvicorn,pyyaml,pandas
Authentication Setup
The Genesys Cloud Python SDK handles OAuth token acquisition and automatic refresh internally. You only need to provide the client credentials and environment domain. The SDK caches the token in memory and requests a new one when the current token expires.
import os
from genesyscloud.platform.client import PlatformClient
from genesyscloud.platform.configuration import Configuration
from genesyscloud.platform.api_exception import ApiException
def initialize_genesys_client() -> PlatformClient:
config = Configuration(
client_id=os.environ["GENESYS_CLIENT_ID"],
client_secret=os.environ["GENESYS_CLIENT_SECRET"],
environment=os.environ.get("GENESYS_ENV", "mypurecloud.com")
)
try:
client = PlatformClient(config)
# Force initial token fetch to validate credentials immediately
client.get_oauth_client().get_token()
return client
except ApiException as e:
if e.status == 401:
raise RuntimeError("Invalid client ID or secret. Verify service account credentials.")
if e.status == 403:
raise RuntimeError("Service account lacks required OAuth scopes. Grant routing:skill:write and routing:profile:write.")
raise e
Implementation
Step 1: Parse Skill Definitions and Normalize Slugs
Genesys Cloud skill names must be unique within an organization. Administrative console exports often contain inconsistent casing or trailing spaces. This step reads a JSON definition file, normalizes names to URL-safe slugs, and detects conflicts before any API calls occur.
import json
import re
from typing import Dict, List, Tuple
def normalize_slug(name: str) -> str:
slug = name.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[\s]+', '-', slug)
return slug
def parse_and_validate_skills(filepath: str) -> Tuple[List[Dict], List[str]]:
with open(filepath, "r") as f:
raw_skills: List[Dict] = json.load(f)
seen_slugs: Dict[str, str] = {}
conflicts: List[str] = []
normalized_skills: List[Dict] = []
for skill in raw_skills:
original_name = skill.get("name", "")
slug = normalize_slug(original_name)
if slug in seen_slugs:
conflicts.append(f"Duplicate slug '{slug}' from '{original_name}' and '{seen_slugs[slug]}'")
continue
seen_slugs[slug] = original_name
normalized_skills.append({
"name": original_name,
"description": skill.get("description", ""),
"slug": slug,
"assign_to_profiles": skill.get("assign_to_profiles", []),
"assign_to_users": skill.get("assign_to_users", [])
})
return normalized_skills, conflicts
Step 2: Create or Update Skill Entities with Retry Logic
Skill creation uses POST /api/v2/routing/skills. If a skill already exists, the API returns a 409 Conflict. You must fetch the existing skill ID and perform a PUT update instead. Genesys Cloud enforces strict rate limits. The following wrapper implements exponential backoff for 429 responses.
Required OAuth Scope: routing:skill:write
import time
import httpx
from genesyscloud.routing.api.skills_api import SkillsApi
from genesyscloud.routing.model.skill_post_body import SkillPostBody
from genesyscloud.routing.model.skill import Skill
from genesyscloud.platform.api_exception import ApiException
def _retry_on_429(func, *args, max_retries: int = 3, base_delay: float = 1.0, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except ApiException as e:
if e.status == 429 and attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
print(f"Rate limited (429). Retrying in {delay}s...")
time.sleep(delay)
else:
raise e
def upsert_skill(client: PlatformClient, skill_data: Dict) -> Skill:
skills_api = SkillsApi(client)
# Construct SDK model
body = SkillPostBody(
name=skill_data["name"],
description=skill_data["description"]
)
try:
response = _retry_on_429(skills_api.create_routing_skill, body=body)
return response
except ApiException as e:
if e.status == 409:
# Conflict: skill already exists. Fetch and return existing.
search_resp = skills_api.get_routing_skills(query=skill_data["name"])
if search_resp.entities and len(search_resp.entities) > 0:
return search_resp.entities[0]
raise RuntimeError(f"409 Conflict for skill '{skill_data['name']}' but search returned empty.")
raise e
Full HTTP Request/Response Cycle Equivalent:
The SDK call above translates to the following raw HTTP exchange. Understanding this cycle helps when debugging SDK serialization issues.
POST /api/v2/routing/skills HTTP/1.1
Host: mycompany.mypurecloud.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"name": "Tier 1 Support",
"description": "Basic customer inquiry handling"
}
Response:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v2/routing/skills/a1b2c3d4-e5f6-7890-abcd-ef1234567890
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Tier 1 Support",
"description": "Basic customer inquiry handling",
"self_uri": "/api/v2/routing/skills/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Step 3: Map Skills to Routing Profiles and Validate Dependencies
Skills must exist before assignment. Routing profiles inherit skills from parent profiles, but explicit assignments override inheritance. This step validates that every skill ID exists, updates the profile, and logs the impact.
Required OAuth Scopes: routing:skill:read, routing:profile:write
from genesyscloud.routing.api.profiles_api import ProfilesApi
from genesyscloud.routing.model.profile_put_body import ProfilePutBody
from genesyscloud.routing.model.skill import Skill
from typing import Dict, List
def validate_skill_dependencies(client: PlatformClient, skill_ids: List[str]) -> Dict[str, Skill]:
skills_api = SkillsApi(client)
valid_skills: Dict[str, Skill] = {}
# Fetch existing skills in batches to handle pagination
offset = 0
page_size = 25
while True:
resp = skills_api.get_routing_skills(page_size=page_size, offset=offset)
for s in resp.entities:
valid_skills[s.id] = s
if resp.entities is None or len(resp.entities) < page_size:
break
offset += page_size
missing = [sid for sid in skill_ids if sid not in valid_skills]
if missing:
raise RuntimeError(f"Dependency validation failed. Skills not found: {missing}")
return valid_skills
def assign_skills_to_profile(client: PlatformClient, profile_id: str, skill_ids: List[str]) -> Dict:
profiles_api = ProfilesApi(client)
# Fetch current profile state
current_profile = profiles_api.get_routing_profile(profile_id=profile_id)
# Merge new skills with existing to preserve manual assignments
existing_skill_ids = [s.id for s in current_profile.routing_skills or []]
merged_skill_ids = list(set(existing_skill_ids + skill_ids))
# Construct update body
body = ProfilePutBody(
name=current_profile.name,
description=current_profile.description,
routing_skills=[{"id": sid} for sid in merged_skill_ids]
)
_retry_on_429(profiles_api.put_routing_profile, profile_id=profile_id, body=body)
return {
"profile_id": profile_id,
"skills_added": [sid for sid in merged_skill_ids if sid not in existing_skill_ids],
"total_skills": len(merged_skill_ids)
}
Step 4: Generate Impact Reports and Expose Search API
Administrators require visibility into configuration drift. This step builds a FastAPI service that exposes a skill search endpoint and generates a CSV impact report using httpx for synchronous background export. Pagination is implemented for the search endpoint to handle large organizations.
import csv
import io
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import StreamingResponse
from genesyscloud.routing.api.skills_api import SkillsApi
from genesyscloud.routing.api.profiles_api import ProfilesApi
app = FastAPI(title="Genesys Skill Config Tool")
def get_client():
return initialize_genesys_client()
@app.get("/api/skills/search")
def search_skills(
q: str = Query(..., min_length=1),
page: int = Query(1, ge=1),
size: int = Query(25, ge=1, le=100)
):
client = get_client()
skills_api = SkillsApi(client)
try:
resp = skills_api.get_routing_skills(query=q, page_size=size, page_number=page)
return {
"page": page,
"size": size,
"total": resp.total,
"entities": [
{"id": s.id, "name": s.name, "description": s.description}
for s in (resp.entities or [])
]
}
except ApiException as e:
raise HTTPException(status_code=e.status, detail=str(e.body))
@app.get("/api/reports/impact")
def generate_impact_report():
client = get_client()
skills_api = SkillsApi(client)
profiles_api = ProfilesApi(client)
# Fetch all skills
all_skills = []
offset = 0
while True:
resp = skills_api.get_routing_skills(page_size=100, offset=offset)
all_skills.extend(resp.entities or [])
if len(resp.entities or []) < 100:
break
offset += 100
# Build impact matrix
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Skill ID", "Skill Name", "Assigned Profile Count", "Sample Profile IDs"])
for skill in all_skills:
# Search profiles containing this skill
profiles_with_skill = []
p_offset = 0
while True:
p_resp = profiles_api.get_routing_profiles(query=skill.id, page_size=50, offset=p_offset)
profiles_with_skill.extend([p.id for p in (p_resp.entities or [])])
if len(p_resp.entities or []) < 50:
break
p_offset += 50
writer.writerow([
skill.id,
skill.name,
len(profiles_with_skill),
"; ".join(profiles_with_skill[:5]) + ("..." if len(profiles_with_skill) > 5 else "")
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=skill_impact_report.csv"}
)
Complete Working Example
The following script combines all components into a single runnable module. It requires environment variables GENESYS_CLIENT_ID, GENESYS_CLIENT_SECRET, and GENESYS_ENV.
import os
import json
import time
import csv
import io
import re
from typing import Dict, List, Tuple
import httpx
import uvicorn
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import StreamingResponse
from genesyscloud.platform.client import PlatformClient
from genesyscloud.platform.configuration import Configuration
from genesyscloud.platform.api_exception import ApiException
from genesyscloud.routing.api.skills_api import SkillsApi
from genesyscloud.routing.api.profiles_api import ProfilesApi
from genesyscloud.routing.model.skill_post_body import SkillPostBody
from genesyscloud.routing.model.profile_put_body import ProfilePutBody
# --- Configuration & Auth ---
def initialize_genesys_client() -> PlatformClient:
config = Configuration(
client_id=os.environ["GENESYS_CLIENT_ID"],
client_secret=os.environ["GENESYS_CLIENT_SECRET"],
environment=os.environ.get("GENESYS_ENV", "mypurecloud.com")
)
try:
client = PlatformClient(config)
client.get_oauth_client().get_token()
return client
except ApiException as e:
if e.status == 401:
raise RuntimeError("Invalid client credentials.")
if e.status == 403:
raise RuntimeError("Missing required OAuth scopes.")
raise e
# --- Retry Logic ---
def retry_on_429(func, *args, max_retries: int = 3, base_delay: float = 1.0, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except ApiException as e:
if e.status == 429 and attempt < max_retries - 1:
time.sleep(base_delay * (2 ** attempt))
else:
raise e
# --- Slug Normalization & Parsing ---
def normalize_slug(name: str) -> str:
slug = name.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[\s]+', '-', slug)
return slug
def parse_skill_file(filepath: str) -> Tuple[List[Dict], List[str]]:
with open(filepath, "r") as f:
raw = json.load(f)
seen = {}
conflicts = []
parsed = []
for item in raw:
slug = normalize_slug(item.get("name", ""))
if slug in seen:
conflicts.append(f"Conflict: '{slug}'")
continue
seen[slug] = item.get("name")
parsed.append({
"name": item.get("name"),
"description": item.get("description", ""),
"slug": slug,
"assign_to_profiles": item.get("assign_to_profiles", [])
})
return parsed, conflicts
# --- Core Operations ---
def upsert_skill(client: PlatformClient, skill: Dict):
skills_api = SkillsApi(client)
body = SkillPostBody(name=skill["name"], description=skill["description"])
try:
return retry_on_429(skills_api.create_routing_skill, body=body)
except ApiException as e:
if e.status == 409:
resp = skills_api.get_routing_skills(query=skill["name"])
if resp.entities:
return resp.entities[0]
raise e
def assign_to_profile(client: PlatformClient, profile_id: str, skill_ids: List[str]):
profiles_api = ProfilesApi(client)
current = profiles_api.get_routing_profile(profile_id=profile_id)
existing = [s.id for s in current.routing_skills or []]
merged = list(set(existing + skill_ids))
body = ProfilePutBody(
name=current.name,
description=current.description,
routing_skills=[{"id": sid} for sid in merged]
)
retry_on_429(profiles_api.put_routing_profile, profile_id=profile_id, body=body)
return {"profile_id": profile_id, "new_count": len(merged)}
# --- FastAPI Service ---
app = FastAPI()
@app.get("/api/skills/search")
def search(q: str = Query(...), page: int = 1, size: int = 25):
client = initialize_genesys_client()
skills_api = SkillsApi(client)
try:
resp = skills_api.get_routing_skills(query=q, page_size=size, page_number=page)
return {"page": page, "total": resp.total, "skills": [{"id": s.id, "name": s.name} for s in (resp.entities or [])]}
except ApiException as e:
raise HTTPException(status_code=e.status, detail=str(e.body))
@app.get("/api/reports/impact")
def impact_report():
client = initialize_genesys_client()
skills_api = SkillsApi(client)
profiles_api = ProfilesApi(client)
all_skills = []
off = 0
while True:
r = skills_api.get_routing_skills(page_size=100, offset=off)
all_skills.extend(r.entities or [])
if len(r.entities or []) < 100: break
off += 100
out = io.StringIO()
w = csv.writer(out)
w.writerow(["Skill ID", "Name", "Profile Count"])
for s in all_skills:
p_r = profiles_api.get_routing_profiles(query=s.id, page_size=50)
w.writerow([s.id, s.name, len(p_r.entities or [])])
out.seek(0)
return StreamingResponse(iter([out.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=impact.csv"})
if __name__ == "__main__":
# Example CLI execution path
if len(os.sys.argv) > 1 and os.sys.argv[1] == "run-script":
client = initialize_genesys_client()
skills, conflicts = parse_skill_file("skills.json")
if conflicts:
print("Conflicts detected:", conflicts)
else:
skill_ids = []
for s in skills:
created = upsert_skill(client, s)
skill_ids.append(created.id)
print(f"Processed skill: {created.id}")
for sid in skill_ids:
print(f"Assigning {sid} to profile example-profile-id")
assign_to_profile(client, "example-profile-id", [sid])
else:
uvicorn.run(app, host="0.0.0.0", port=8000)
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The service account credentials are invalid, expired, or the environment domain is misspelled.
- Fix: Verify
GENESYS_CLIENT_IDandGENESYS_CLIENT_SECRET. Ensure the domain matches your organization exactly (e.g.,acme.mypurecloud.comnotacme.pure.cloud). - Code Fix: The
initialize_genesys_clientfunction catches this and raises a descriptiveRuntimeError. Log the rawApiException.bodyto see if Genesys returns a specific token rejection reason.
Error: 403 Forbidden
- Cause: The OAuth client lacks required scopes. Skill operations require
routing:skill:write. Profile updates requirerouting:profile:write. - Fix: Navigate to the Genesys Cloud admin console, open the service account configuration, and add the missing scopes. Save and wait 60 seconds for scope propagation.
- Code Fix: Check the
client.get_oauth_client().get_token()response payload. Thescopeclaim will list exactly what was granted.
Error: 409 Conflict on Skill Creation
- Cause: A skill with the exact same name already exists in the organization. Genesys Cloud enforces strict name uniqueness.
- Fix: The
upsert_skillfunction automatically handles this by switching to a GET search and returning the existing entity. If you require unique names, append a timestamp or environment suffix before calling the API. - Code Fix: Inspect the
conflictslist returned byparse_skill_filebefore execution. Pre-resolve naming collisions in your definition file.
Error: 429 Too Many Requests
- Cause: Rate limit cascade triggered by bulk operations or concurrent SDK instances. Genesys Cloud limits routing API calls to 100 requests per minute per client ID.
- Fix: The
retry_on_429wrapper implements exponential backoff. If failures persist, increasebase_delayto 2.0 seconds or implement a token bucket limiter in your orchestration layer. - Code Fix: Monitor the
Retry-Afterheader in raw HTTP responses. The SDK does not automatically parse it, so manual sleep intervals are required.