Skip to main content

Recipe — Task Pool with candidateUsers and candidateGroups

This recipe shows how to build a process where tasks can be assigned to individual users or groups, and how the frontend manages the claim flow.

The Process

[Open Ticket] ──→ [Triage] ──→ [Resolve Ticket] ──→ [end]
userTask userTask userTask
assignee: candidateGroups: candidateUsers:
{{variables [tier1-support] {{variables
.openedBy}} .assignedAnalysts}}

The first task goes directly to whoever opened the ticket. Triage is a pool task for any tier1-support group member. Resolution goes to specific analysts determined by the process.

BPMN Configuration

Triage task:

<bpmn:userTask id="triage" name="Triage">
<bpmn:extensionElements>
<stateway:assignmentDefinition candidateGroups="tier1-support" />
</bpmn:extensionElements>
</bpmn:userTask>

Resolve task:

<bpmn:userTask id="resolve" name="Resolve Ticket">
<bpmn:extensionElements>
<!-- assignedAnalysts is a CSV variable set by the preceding service task -->
<stateway:assignmentDefinition candidateUsers="{{variables.assignedAnalysts}}" />
</bpmn:extensionElements>
</bpmn:userTask>

Backend: Create Session

The support agent needs both mechanisms: group (tier1-support) for the triage task, and individual nomination for the resolve task:

const { session_token } = await fetch('https://api.stateway.io/v1/sessions', {
method: 'POST',
headers: { 'X-API-Key': process.env.STATEWAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: user.id,
user_display: user.name,
ttl: 3600,
scope: {
definitions: [{
key: 'support-ticket',
roles: ['tier1-support'], // to receive candidateGroups tasks
candidate_as_user: true, // to receive candidateUsers tasks
can_start: false,
max_concurrent: null
}]
}
})
}).then(r => r.json());

Frontend: Support Agent

import { StatewayClient } from '@stateway/frontend-sdk';

const sw = new StatewayClient({ endpoint: 'wss://ws.stateway.io', sessionToken });

sw.on('work.snapshot', (items) => {
items.forEach(task => {
const isPool = task.candidateUsers.length > 0
|| task.candidateGroups.length > 0;

renderCard({
task,
showClaimButton: isPool && !task.isClaimed,
showUnclaimButton: isPool && task.isClaimed && task.claimedBy === myUserId,
canComplete: !isPool || (task.isClaimed && task.claimedBy === myUserId),
});
});
});

sw.on('task.claimed_by_other', ({ taskId, claimedBy }) => {
updateCard(taskId, { claimedBy, canComplete: false });
});

async function handleClaim(taskId: string) {
try {
await sw.claimTask(taskId);
updateCard(taskId, { isClaimed: true, claimedBy: myUserId, canComplete: true });
} catch (err) {
if (err.code === 'CLAIM_CONFLICT')
showToast('Another agent got there first');
}
}

async function handleComplete(taskId: string, values: object) {
await sw.completeTask(taskId, values);
}

Key Points

  • roles: ['tier1-support'] enables the group-based claim (candidateGroups)
  • candidate_as_user: true enables the individual nomination claim (candidateUsers)
  • Both flags are needed because the same user may receive tasks via either mechanism
  • task.isClaimed and task.claimedBy let the frontend render the correct buttons
  • The task.claimed_by_other event removes the task from other agents' inboxes in real time