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:
{{session [tier1-support] {{variables
.userId}} .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
Open Ticket task:
<bpmn:userTask id="openTicket" name="Open Ticket">
<bpmn:extensionElements>
<!-- this task is created while startProcess runs, so session.userId is the user who started the instance -->
<stateway:assignmentDefinition assignee="{{session.userId}}" />
</bpmn:extensionElements>
</bpmn:userTask>
Do not fill a variable such as openedBy from the variables passed to sw.startProcess(). They come from the browser, so any user who can start the process could put another user's id there. If a later task must go back to whoever opened the ticket, record the starter on the startEvent ({{session.userId}} → openedBy) and assign it with {{variables.openedBy}}: by then {{session.userId}} is the user who completed the previous task. See Assigning to Whoever Started the Instance.
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),
});
});
});
// Server confirmed our claim — claim state comes from the event, not from claimTask()
sw.on('task.claimed', ({ taskId, claimedBy }) => {
updateCard(taskId, { isClaimed: true, claimedBy, canComplete: true });
});
sw.on('task.claimed_by_other', ({ taskId, claimedBy }) => {
updateCard(taskId, { isClaimed: true, claimedBy, canComplete: false });
});
// Sent to every eligible agent, including the one who completed the task
sw.on('task.completed', ({ taskId }) => {
removeCard(taskId);
});
// Same recipients as task.completed — the task ended before anyone completed it
sw.on('task.cancelled', ({ taskId }) => {
removeCard(taskId);
});
async function handleClaim(taskId: string) {
try {
// No local state update here — the card flips when task.claimed arrives
await sw.claimTask(taskId);
} catch (err) {
if (err.code === 'CLAIM_CONFLICT')
showToast('Another agent got there first');
}
}
async function handleComplete(taskId: string, values: object) {
// No local state update here — the card is removed when task.completed arrives
await sw.completeTask(taskId, values);
}
Key Points
{{session.userId}}assigns the first task to whoever started the ticket. Stateway takes that value from the session, never from the variables sent bystartProcess- In a later task,
{{session.userId}}is the user who completed the previous one. Use it inioMappingoutputs to record who triaged or resolved the ticket ({{session.userId}}→triagedBy); see Recording who completed a task roles: ['tier1-support']enables the group-based claim (candidateGroups)candidate_as_user: trueenables the individual nomination claim (candidateUsers)- Both flags are needed because the same user may receive tasks via either mechanism
task.isClaimedandtask.claimedBylet the frontend render the correct buttons- Claim state is never written optimistically:
claimTask()just resolves the request, and the card flips ontask.claimed(this agent) ortask.claimed_by_other(other agents) — see the SDK reference for the recipient of every event task.completedis sent to every eligible agent, including the completer, so each frontend removes the card on the eventtask.cancelledgoes to the same agents astask.completed; remove the card on it too, since completing a cancelled task fails