What?

This is a quick article to document an interim replacement for Zoho Desk’s built-in round-robin ticket assignment using a custom Deluge function.

When a ticket is created, the function assigns it to an eligible agent with the fewest outstanding tickets in the same department. It excludes specified email addresses using a list, which is useful for managers, administrators or other agents who should not receive new tickets.

For this example, outstanding workload means open tickets plus on-hold tickets. Closed tickets are not included. Agents tied for the lowest workload are selected at random, so the function balances the current workload rather than maintaining a fixed rotation.

Why?

In the Desk setup I was working on, the built-in round-robin was not assigning tickets as expected. New tickets still needed an owner while that issue was being investigated, and allocating them manually was adding another task for the team.

Zoho Desk Round Robin Rule showing the error: Unable to process your request
Zoho Desk’s built-in Round Robin Rule displaying “Unable to process your request”. Screenshot: Joel Lipman.

The interim requirement was straightforward: check the workload of active agents, leave certain people out of the allocation, and assign the ticket to someone who has fewer outstanding tickets. Keeping the exclusions in a list also avoids changing the selection logic whenever another address needs to be excluded.

This documents a workaround for that situation, rather than a diagnosis of the built-in assignment behaviour.

How?

The workflow runs on ticket creation and passes the ticket’s internal ID to fn_Ticket_AssignLeastLoadedAgent. The function retrieves the department, filters its agents, compares their ticket counts and updates assigneeId.

1. Prepare the organisation ID and connection

In Desk, open Setup → Developer Space → API and copy the Org ID. Keep it available for the v_OrgID setting in the function. [1]

For authentication, open Setup → Developer Space → Connections → Create Connection. Select Zoho Desk, enter a name and use desk_connection as the connection link name. Select the scopes below, then choose Create and Connect and authorise the connection. An existing connection with the same access can also be used. [2]

ScopeUsed for
Desk.basic.READBasic Desk access used by the agent endpoint
Desk.agents.READReading eligible agents
Desk.tickets.READReading the ticket and agent workload counts
Desk.tickets.UPDATEUpdating the ticket owner

The connection’s authorised user must have access to the relevant department, agents and tickets. OAuth scopes do not grant permissions that the user does not already have.

2. Create the ticket workflow

Go to Setup → Automation → Workflows, select the department and click Create Rule. Enter the following values, then click Next. [3]

SettingValue
ModuleTickets
Rule Namewkflw_Tickets_AssignLeastLoadedAgent
DescriptionInterim assignment of new tickets to the eligible agent with the lowest outstanding workload.
ActiveLeave unticked while preparing the function; activate for a controlled test afterwards.

3. Select the creation trigger and criteria

Under Execute on, select Create only and click Next. Leave the other triggers unticked so an owner update does not repeatedly reallocate the ticket.

Leave the criteria empty to cover all newly created tickets in this workflow’s department, or add filters to limit the intended scope. Click Next. The function will replace an existing default owner when another agent is selected, so restrict the rule if some tickets require a fixed owner.

4. Add a new custom function

Under Actions, click the add icon and choose Custom Functions → New. If that option is unavailable, create it under Setup → Automation → Workflows → Custom Functions → New Functions, then associate it using Existing. [4]

Enter these details:

SettingValue
Function nameAssign Ticket to the least-loaded agent
Method Namefn_Ticket_AssignLeastLoadedAgent
ModuleTickets
Argument Namep_TicketID
Argument valueSelect the ticket’s Ticket ID placeholder.

Map the internal Ticket ID, not the customer-facing Ticket Number. The input name must match p_TicketID in the script.

5. Paste the function

Paste the following body into the Function Script editor. The method name and argument are configured in Desk, so no CRM-style function declaration is needed.

/* *******************************************************************************
Function:       fn_Ticket_AssignLeastLoadedAgent
Label:          Assign Ticket to the agent with the lowest workload
Trigger:        Ticket creation workflow
Purpose:        Interim assignment to an active, confirmed, non-light agent
                in the ticket's department, excluding configured email addresses.
Input:          p_TicketID, mapped to the Desk Ticket ID (not Ticket Number)
Workload:       Open tickets plus on-hold tickets in the same department.
Tie-break:      Random selection between agents sharing the lowest count.
Connection:     desk_connection
Author:         Joel Lipman
Date Created:   2026-11-04
Notes:          Paste this body into the Zoho Desk function editor.
                Stops if 200 agents are returned or any eligible count is missing.
                Uses conditional blocks without bare return statements.
******************************************************************************* */
try
{
	//
	// Configuration: use your own organisation ID, API domain and connection
	v_OrgID = "YOUR_DESK_ORG_ID";
	v_ApiBase = "https://desk.zoho.eu/api/v1";
	l_ExcludedEmails = List();
	l_ExcludedEmails.add("manager@example.com");
	l_ExcludedEmails.add("administrator@example.com");
	//
	// Normalise exclusions so email comparisons ignore case and surrounding spaces
	l_NormalisedExcludedEmails = List();
	for each v_ExcludedEmail in l_ExcludedEmails
	{
		if(!isNull(v_ExcludedEmail))
		{
			l_NormalisedExcludedEmails.add(v_ExcludedEmail.toString().trim().toLowerCase());
		}
	}
	v_CanContinue = true;
	v_Message = "";
	v_TicketID = ifnull(p_TicketID,"").toString().trim();
	if(v_TicketID == "")
	{
		v_CanContinue = false;
		v_Message = "Assignment stopped: no Ticket ID supplied.";
	}
	m_Headers = Map();
	m_Headers.put("orgId",v_OrgID);
	m_Headers.put("Content-Type","application/json");
	//
	// Retrieve the ticket and its department
	if(v_CanContinue)
	{
		r_Ticket = invokeurl
		[
			url :v_ApiBase + "/tickets/" + v_TicketID
			type :GET
			headers:m_Headers
			connection:"desk_connection"
		];
		if(isNull(r_Ticket.get("id")) || isNull(r_Ticket.get("departmentId")))
		{
			v_CanContinue = false;
			v_Message = "Assignment stopped: ticket could not be retrieved. " + r_Ticket;
		}
		else
		{
			v_DepartmentID = r_Ticket.get("departmentId").toString();
			v_CurrentAgentID = ifnull(r_Ticket.get("assigneeId"),"").toString();
			v_StatusType = ifnull(r_Ticket.get("statusType"),"").toString().toLowerCase();
		}
	}
	//
	// Retrieve eligible agents from the ticket's department
	l_AgentIDs = List();
	if(v_CanContinue)
	{
		m_AgentParams = Map();
		m_AgentParams.put("from",0);
		m_AgentParams.put("limit",200);
		m_AgentParams.put("status","ACTIVE");
		m_AgentParams.put("isConfirmed",true);
		m_AgentParams.put("isLightAgent",false);
		r_Agents = invokeurl
		[
			url :v_ApiBase + "/departments/" + v_DepartmentID + "/agents"
			type :GET
			parameters:m_AgentParams
			headers:m_Headers
			connection:"desk_connection"
		];
		if(isNull(r_Agents.get("data")))
		{
			v_CanContinue = false;
			v_Message = "Assignment stopped: agents could not be retrieved. " + r_Agents;
		}
		else
		{
			l_Agents = r_Agents.get("data");
			if(l_Agents.size() == 200)
			{
				v_CanContinue = false;
				v_Message = "Assignment stopped: agent pagination is required for this department.";
			}
			else
			{
				for each m_Agent in l_Agents
				{
					v_AgentEmail = ifnull(m_Agent.get("emailId"),"").toString().trim().toLowerCase();
					if(m_Agent.get("status") == "ACTIVE" && m_Agent.get("isConfirmed") == true && !l_NormalisedExcludedEmails.contains(v_AgentEmail))
					{
						l_AgentIDs.add(m_Agent.get("id").toString());
					}
				}
				if(l_AgentIDs.isEmpty())
				{
					v_CanContinue = false;
					v_Message = "Assignment stopped: no eligible agents in the ticket's department.";
				}
			}
		}
	}
	//
	// Retrieve all eligible agents' ticket counts in one request
	m_AgentCounts = Map();
	if(v_CanContinue)
	{
		m_CountParams = Map();
		m_CountParams.put("departmentId",v_DepartmentID);
		m_CountParams.put("agentIds",l_AgentIDs.toString(","));
		r_Counts = invokeurl
		[
			url :v_ApiBase + "/agentsTicketsCount"
			type :GET
			parameters:m_CountParams
			headers:m_Headers
			connection:"desk_connection"
		];
		if(isNull(r_Counts.get("data")))
		{
			v_CanContinue = false;
			v_Message = "Assignment stopped: ticket counts could not be retrieved. " + r_Counts;
		}
		else
		{
			for each m_Count in r_Counts.get("data")
			{
				if(!isNull(m_Count.get("agentId")) && !isNull(m_Count.get("openCount")) && !isNull(m_Count.get("onholdCount")))
				{
					v_AgentID = m_Count.get("agentId").toString();
					v_Workload = m_Count.get("openCount").toLong() + m_Count.get("onholdCount").toLong();
					//
					// Exclude this ticket from its current owner's workload
					if(v_AgentID == v_CurrentAgentID && (v_StatusType == "open" || v_StatusType == "on hold") && v_Workload > 0)
					{
						v_Workload = v_Workload - 1;
					}
					m_AgentCounts.put(v_AgentID,v_Workload);
				}
			}
		}
	}
	//
	// Find the lowest workload and retain every tied agent
	v_LowestCount = -1;
	l_LowestAgentIDs = List();
	if(v_CanContinue)
	{
		for each v_AgentID in l_AgentIDs
		{
			if(!m_AgentCounts.containKey(v_AgentID))
			{
				v_CanContinue = false;
				v_Message = "Assignment stopped: ticket count missing for agent " + v_AgentID;
			}
			else
			{
				v_Workload = m_AgentCounts.get(v_AgentID);
				if(v_LowestCount == -1 || v_Workload < v_LowestCount)
				{
					v_LowestCount = v_Workload;
					l_LowestAgentIDs = List();
					l_LowestAgentIDs.add(v_AgentID);
				}
				else if(v_Workload == v_LowestCount)
				{
					l_LowestAgentIDs.add(v_AgentID);
				}
			}
		}
	}
	//
	// Random upper bound is exclusive: valid indices are 0 to size minus 1
	if(v_CanContinue)
	{
		v_SelectedIndex = 0;
		if(l_LowestAgentIDs.size() > 1)
		{
			v_SelectedIndex = randomNumber(0,l_LowestAgentIDs.size());
		}
		v_SelectedAgentID = l_LowestAgentIDs.get(v_SelectedIndex);
		if(v_SelectedAgentID == v_CurrentAgentID)
		{
			v_Message = "Ticket " + v_TicketID + " already belongs to selected agent " + v_SelectedAgentID;
		}
		else
		{
			//
			// Update the ticket owner
			m_Update = Map();
			m_Update.put("assigneeId",v_SelectedAgentID);
			r_Update = invokeurl
			[
				url :v_ApiBase + "/tickets/" + v_TicketID
				type :PATCH
				parameters:m_Update.toString()
				headers:m_Headers
				connection:"desk_connection"
			];
			if(ifnull(r_Update.get("assigneeId"),"").toString() == v_SelectedAgentID)
			{
				v_Message = "Ticket " + v_TicketID + " assigned to agent " + v_SelectedAgentID + ". Existing workload: " + v_LowestCount;
			}
			else
			{
				v_Message = "Ticket assignment failed: " + r_Update;
			}
		}
	}
	info v_Message;
}
catch(e)
{
	info "fn_Ticket_AssignLeastLoadedAgent failed: " + e.toString();
}

6. Set your organisation, exclusions and API domain

Replace YOUR_DESK_ORG_ID with your own Desk organisation ID, keeping the quotation marks. The published example contains no client organisation ID or client-specific connection name.

Edit the exclusion list near the start of the script:

l_ExcludedEmails = List();
l_ExcludedEmails.add("manager@example.com");
l_ExcludedEmails.add("administrator@example.com");

Add one line per address to exclude. To exclude nobody, keep l_ExcludedEmails = List(); and remove the add(...) lines. Addresses are trimmed and converted to lowercase before comparison.

The default API base is https://desk.zoho.eu/api/v1 for EU accounts. For a US account use https://desk.zoho.com/api/v1; other data centres need their corresponding Desk API domain. If your connection link name differs, replace every connection:"desk_connection" occurrence. [5]

7. Test, associate and activate

Save the function and use Save & Execute Script with a test ticket’s ID. This performs a real owner update. Associate the saved function with the workflow, save the rule and activate it for a controlled ticket-creation test.

Check the ticket owner and Setup → Automation → Workflows → Custom Functions → Execution Log. The script logs its assignment or the reason it stopped. [4]

TestExpected result
One eligible agent has fewer outstanding tickets.That agent receives the ticket.
Two eligible agents share the lowest count.Either tied agent may receive it.
An excluded agent has the lowest count.That agent is not selected.
The selected agent already owns the ticket.No owner update is submitted.
No eligible agents or incomplete workload counts are returned.The current owner is retained and a reason is logged.

Create test tickets one at a time and allow the workload counts to refresh between tests. During the interim period, disable competing automatic assignment for the same ticket scope so another rule does not overwrite the selected owner.

8. Understand the allocation

For example, if Alex has 7 outstanding tickets, Blair has 3 and Casey has 5, Blair is selected. An agent with fewer tickets can continue to receive new work until the workloads become closer. This does not promise that every agent receives the same number of newly created tickets.

“Active” means an enabled, confirmed Desk agent. The function does not require the agent to be online and does not evaluate shifts, leave, skills, teams or business hours. Light agents are filtered out by the agent-list request.

The workload is measured within the ticket’s department. When the ticket already has an owner and is open or on hold, one ticket is subtracted from that owner’s count before comparison. This assumes the returned count already includes the ticket.

The script uses the agent-count API rather than downloading every ticket. It does not add overdue counts to the workload because those tickets would otherwise be counted again. [5]

9. Keep the interim limits in mind

Ticket counts can lag behind recent changes. Two executions running at the same time can read the same counts and choose the same agent; the function does not reserve an allocation or store a last-assigned pointer. The current-ticket subtraction can also be approximate while counts are catching up.

This version requests up to 200 agents. If it receives a full page of 200, it stops and logs that pagination is required rather than selecting from a potentially incomplete pool. Missing counts are not treated as zero. On a stopped run, the current owner, or unassigned state, remains as it was.

The example should be saved and tested in the target Desk account before broader use. When built-in assignment has been resolved and tested, deactivate wkflw_Tickets_AssignLeastLoadedAgent before restoring the normal allocation for the same scope.

References

  1. Zoho Deluge: finding the Desk organisation ID. Use the settings location; this article authenticates with an OAuth connection.
  2. Zoho Desk: setting up connections.
  3. Zoho Desk: creating workflow rules.
  4. Zoho Desk: creating and deploying custom functions.
  5. Zoho Desk API documentation: department agents, agent ticket counts, ticket retrieval and ticket update.
  6. Zoho Deluge: randomNumber. The upper bound is exclusive, as stated in the documentation’s note and examples.

Article, Deluge example and screenshot: Joel Lipman. Documentation checked on 4 November 2026.