CXone Admin API — Bulk Update Agent Skill Proficiencies via REST
What You Will Build
- This tutorial demonstrates how to update skill proficiencies for multiple agents simultaneously using the NICE CXone Admin REST API.
- The solution uses the
PATCH /api/v2/agentsendpoint with a batch update payload to modify skill assignments efficiently. - The programming language covered is Python 3.10+ using the
httpxlibrary for robust HTTP handling.
Prerequisites
- OAuth Client Type: Service Account or Web Server (Client Credentials Grant or Authorization Code Grant).
- Required Scopes:
admin:agent:write(Required for updating agent profiles)admin:agent:read(Required for retrieving current agent data if needed for comparison)
- SDK/API Version: CXone Admin API v2.
- Language/Runtime: Python 3.10 or higher.
- External Dependencies:
httpx: For asynchronous HTTP requests with native support for retries and timeouts.pydantic: For data validation and type safety (optional but recommended for production).
Install dependencies via pip:
pip install httpx pydantic
Authentication Setup
NICE CXone uses OAuth 2.0 for authentication. For server-to-server integrations, the Client Credentials Grant flow is the standard approach. This flow exchanges a client ID and secret for an access token.
The following code demonstrates how to obtain an access token and handle the expiration logic.
import httpx
from typing import Optional
import time
class CXoneAuth:
def __init__(self, client_id: str, client_secret: str, realm: str):
self.client_id = client_id
self.client_secret = client_secret
self.realm = realm
self.token_url = f"https://{realm}.cxone.com/oauth/token"
self.access_token: Optional[str] = None
self.token_expiry: float = 0
async def get_access_token(self) -> str:
"""
Retrieves a new access token if the current one is expired or missing.
"""
if self.access_token and time.time() < self.token_expiry:
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(
self.token_url,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
},
headers={
"Content-Type": "application/x-www-form-urlencoded"
}
)
if response.status_code != 200:
raise Exception(f"Failed to obtain access token: {response.text}")
token_data = response.json()
self.access_token = token_data["access_token"]
# Set expiry slightly before actual expiry to avoid race conditions
self.token_expiry = time.time() + token_data["expires_in"] - 10
return self.access_token
async def get_headers(self) -> dict:
"""
Returns the standard headers required for CXone API calls.
"""
token = await self.get_access_token()
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
Key Authentication Details:
- The
realmparameter is critical. It determines the base URL (e.g.,na1,eu1,ap1). - The
access_tokenexpires typically after 1 hour. Theget_access_tokenmethod checks the timestamp before making a new request, preventing unnecessary network calls.
Implementation
Step 1: Define the Skill Proficiency Payload
The CXone Agent entity contains a skills array. Each skill object in this array defines the relationship between an agent and a specific skill. To update proficiencies, you must provide the correct structure for the skills array within the agent update payload.
The PATCH operation on /api/v2/agents supports partial updates. However, when updating the skills array, the API expects the complete desired state of skills for that agent, not just the delta. Therefore, you must include all existing skills you wish to retain, along with the updated or new skills.
Skill Object Structure:
{
"skill": {
"id": "skill-uuid-here"
},
"proficiency": 85,
"available": true
}
id: The UUID of the skill.proficiency: An integer between 0 and 100. This value influences routing weight in skills-based routing.available: A boolean indicating if the agent is available for this skill.
Step 2: Construct the Batch Update Payload
To bulk-update agents, you do not call the endpoint once per agent. Instead, you can send multiple PATCH requests in parallel or use a loop with concurrency controls. The CXone API does not have a single “bulk update all agents” endpoint that accepts a list of agents in one JSON body. Instead, “bulk” in this context means automating individual PATCH calls efficiently.
We will use httpx.AsyncClient to handle concurrent requests. This is crucial because updating 1,000 agents sequentially could take minutes. With concurrency, it can be done in seconds.
Important: CXone enforces rate limits. The default rate limit for Admin APIs is typically 100 requests per minute per tenant, but this can vary. We must implement exponential backoff for 429 Too Many Requests responses.
Step 3: Implement Concurrent Updates with Retry Logic
The following class handles the core logic: fetching agents (optional, if you already have IDs), constructing the payloads, and executing the updates concurrently.
import asyncio
import httpx
from typing import List, Dict, Any, Optional
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AgentSkillUpdater:
def __init__(self, auth: CXoneAuth, realm: str, max_concurrency: int = 10):
self.auth = auth
self.realm = realm
self.base_url = f"https://{realm}.cxone.com/api/v2"
self.max_concurrency = max_concurrency
self.semaphore = asyncio.Semaphore(max_concurrency)
async def update_agent_skills(
self,
agent_id: str,
skills_payload: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Updates the skills for a single agent.
Args:
agent_id: The UUID of the agent.
skills_payload: A list of skill objects to set as the agent's complete skill set.
Returns:
The response JSON from the API.
"""
url = f"{self.base_url}/agents/{agent_id}"
headers = await self.auth.get_headers()
# The payload must include only the fields being updated
payload = {
"skills": skills_payload
}
async with httpx.AsyncClient(timeout=30.0) as client:
retries = 3
for attempt in range(retries):
try:
async with self.semaphore:
response = await client.patch(
url,
json=payload,
headers=headers
)
if response.status_code == 200:
logger.info(f"Successfully updated agent {agent_id}")
return response.json()
elif response.status_code == 429:
# Rate limited. Wait and retry.
wait_time = 2 ** attempt
logger.warning(f"Rate limited for agent {agent_id}. Retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
continue
elif response.status_code == 404:
logger.error(f"Agent {agent_id} not found.")
return {"error": "Agent not found"}
elif response.status_code == 400:
logger.error(f"Bad request for agent {agent_id}: {response.text}")
return {"error": "Bad request", "details": response.text}
else:
logger.error(f"Unexpected error for agent {agent_id}: {response.status_code} {response.text}")
return {"error": response.text}
except httpx.RequestError as e:
logger.error(f"Network error for agent {agent_id}: {e}")
await asyncio.sleep(1)
continue
return {"error": "Max retries exceeded"}
async def bulk_update_agents(
self,
updates: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Executes concurrent updates for a list of agents.
Args:
updates: A list of dictionaries, each containing:
- 'agent_id': str
- 'skills': List[Dict]
Returns:
A list of response dictionaries.
"""
tasks = []
for update in updates:
task = asyncio.create_task(
self.update_agent_skills(
agent_id=update["agent_id"],
skills_payload=update["skills"]
)
)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"Task for agent {updates[i]['agent_id']} failed with exception: {result}")
processed_results.append({"error": str(result)})
else:
processed_results.append(result)
return processed_results
Explanation of the Code:
asyncio.Semaphore: This limits the number of concurrent HTTP requests tomax_concurrency. Without this, you might open thousands of connections simultaneously, triggering rate limits or exhausting local OS resources.- Retry Logic: The loop inside
update_agent_skillshandles429errors by waiting exponentially longer between attempts. This is essential for bulk operations. - Payload Structure: The
PATCHrequest only sends theskillsfield. This ensures that other agent attributes (like name, email, or default queues) remain untouched.
Complete Working Example
The following script ties everything together. It assumes you have a list of agent IDs and the desired skill proficiencies.
import asyncio
import os
from cxone_auth import CXoneAuth # Assuming the auth class is in cxone_auth.py
from agent_skill_updater import AgentSkillUpdater
async def main():
# Configuration
CLIENT_ID = os.getenv("CXONE_CLIENT_ID")
CLIENT_SECRET = os.getenv("CXONE_CLIENT_SECRET")
REALM = os.getenv("CXONE_REALM", "na1")
if not CLIENT_ID or not CLIENT_SECRET:
raise ValueError("Missing CXONE_CLIENT_ID or CXONE_CLIENT_SECRET environment variables.")
# Initialize Authentication
auth = CXoneAuth(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
realm=REALM
)
# Initialize Updater
updater = AgentSkillUpdater(auth=auth, realm=REALM, max_concurrency=10)
# Define Updates
# Scenario: Update proficiency for 'Skill A' and 'Skill B' for two agents.
# Note: In a real scenario, you would likely fetch the current skills first
# to ensure you are not deleting existing skills you want to keep.
skill_a_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
skill_b_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901"
updates = [
{
"agent_id": "agent-uuid-1",
"skills": [
{
"skill": {"id": skill_a_id},
"proficiency": 90,
"available": True
},
{
"skill": {"id": skill_b_id},
"proficiency": 75,
"available": True
}
]
},
{
"agent_id": "agent-uuid-2",
"skills": [
{
"skill": {"id": skill_a_id},
"proficiency": 100,
"available": True
},
{
"skill": {"id": skill_b_id},
"proficiency": 50,
"available": False
}
]
}
]
print("Starting bulk update...")
results = await updater.bulk_update_agents(updates)
print("\nResults:")
for i, result in enumerate(results):
if "error" in result:
print(f"Agent {updates[i]['agent_id']}: FAILED - {result}")
else:
print(f"Agent {updates[i]['agent_id']}: SUCCESS")
if __name__ == "__main__":
asyncio.run(main())
Common Errors & Debugging
Error: 400 Bad Request - “Invalid skill ID”
Cause: The skill.id provided in the payload does not exist in the CXone tenant, or the format is incorrect.
Fix: Verify the skill UUIDs. You can list all skills using GET /api/v2/skills to find the correct IDs. Ensure the UUIDs are valid strings.
Error: 400 Bad Request - “Proficiency out of range”
Cause: The proficiency field must be an integer between 0 and 100.
Fix: Validate your input data before sending. Ensure no floating-point numbers are passed.
Error: 403 Forbidden
Cause: The OAuth token lacks the admin:agent:write scope.
Fix: Re-authenticate with the correct scopes. Check your client application settings in the CXone Admin Portal under Platform > OAuth Applications.
Error: 429 Too Many Requests
Cause: You exceeded the tenant’s rate limit for API calls.
Fix: The provided code includes exponential backoff. If you still see 429s, reduce the max_concurrency parameter in AgentSkillUpdater. Start with 5 and increase gradually.
Error: 409 Conflict
Cause: Another process is currently updating the same agent. CXone uses optimistic locking.
Fix: Implement a retry mechanism with a random jitter. The current retry logic handles this implicitly by retrying after a delay.