Using Skills
Model provider attributes and enforce scheduling constraints with a flexible, composable skill system.
Overview#
Skills are a generic abstraction for any provider attribute your organization cares about — licensed regions, enrollments, spoken languages, clinical specialties, and more. Rather than building separate systems for each attribute type, you define skills, assign values to providers, and enforce skill requirements.
This gives you a single, composable mechanism that covers a wide range of scheduling constraints without custom logic for each one.
Core concepts#
Skills#
A skill is a named category of provider attribute. Each skill has an id and a set of possible values.
| Example skill | Possible values |
|---|---|
| Licensed Region | US-KY, US-OH, US-IN, … |
| Hospital Enrollment | st_mary, general_east, childrens_central, … |
| Language | en, es, fr, zh, … |
| Specialty | cardiology, radiology, emergency_medicine, … |
Skill assignments#
A provider can hold zero or more values for each skill, as well as a valid range for the assignment:
| Provider | Skill | Value | Range |
|---|---|---|---|
| Dr. Reed | Licensed Region | US-KY | — |
| Dr. Reed | Licensed Region | US-OH | 2026-01-10 onwards |
| Dr. Reed | Licensed Region | US-WI | until 2028-12-31 |
Skill requirements#
A skill requirement is a constraint — typically placed on a shift — that a provider must satisfy in order to be eligible. Requirements are composable: you can combine simple checks with and / or logic to express complex eligibility rules.
Requirement schema#
Skill requirements use a recursive, tree-structured schema. There are two node types: leaf nodes that check a single skill value, and logical nodes that combine other requirements.
equal — leaf requirement#
Asserts that a provider holds a specific value for a specific skill.
{
"type": "equal",
"skill": "licensed-region-id",
"value": "US-KY"
}and — all must match#
{
"type": "and",
"entries": [
{ "type": "equal", "skill": "licensed-region-id", "value": "US-KY" },
{ "type": "equal", "skill": "enrollment-id", "value": "st_mary" }
]
}or — at least one must match#
{
"type": "or",
"entries": [
{ "type": "equal", "skill": "licensed-region-id", "value": "US-KY" },
{ "type": "equal", "skill": "licensed-region-id", "value": "US-OH" }
]
}Full type definition#
The schema is recursive — and and or nodes can contain any mix of equal, and, and or children, allowing arbitrary nesting depth.
type SkillRequirement =
| {
type: 'equal';
skill: string;
value: string;
}
| {
type: 'and' | 'or';
entries: SkillRequirement[];
};Demand forecasts by skill#
A demand forecast says how many staff a role needs, and when. For some roles it can also say which skills those staff need, such as a language or a licensed region, so the forecast asks for "3 Spanish speakers licensed in Kentucky" rather than just "3 people".
Which columns a role takes#
Untether sets up which roles are broken down by which skills. Each of those skills becomes one extra column when you upload demand, and each column accepts a fixed list of values. GET /v1/forecasting/demand-config/{teamId} returns every role's columns and values. Use the column names exactly as given. They are based on the skill's name (Licensed Region is licensed_region).
A role that is not broken down by any skill takes only the four standard columns: role, startDate, endDate and demand.
An example#
Say a role needs, from 08:00 to 16:00: 3 Spanish-speaking staff licensed in Kentucky, 2 English-speaking staff licensed in Ohio, and 1 more person whose language and licence don't matter. That is three rows over the same hours:
role,startDate,endDate,demand,language,licensed_region
<role id>,2026-06-01T08:00:00-04:00,2026-06-01T16:00:00-04:00,3,es,US-KY
<role id>,2026-06-01T08:00:00-04:00,2026-06-01T16:00:00-04:00,2,en,US-OH
<role id>,2026-06-01T08:00:00-04:00,2026-06-01T16:00:00-04:00,1,,The same rows as JSON. For the row with no requirement, leave the keys out (or send null or an empty string):
[
{
"role": "<role id>",
"startDate": "2026-06-01T08:00:00-04:00",
"endDate": "2026-06-01T16:00:00-04:00",
"demand": 3,
"language": "es",
"licensed_region": "US-KY"
},
{
"role": "<role id>",
"startDate": "2026-06-01T08:00:00-04:00",
"endDate": "2026-06-01T16:00:00-04:00",
"demand": 2,
"language": "en",
"licensed_region": "US-OH"
},
{
"role": "<role id>",
"startDate": "2026-06-01T08:00:00-04:00",
"endDate": "2026-06-01T16:00:00-04:00",
"demand": 1
}
]The rows add up: the role needs 6 staff in total over those hours. A view filtered to Spanish speakers counts the Spanish row plus the row with no requirement.
Rules worth knowing#
- Overlapping rows. Rows for the same role may cover the same hours as long as their skill values differ, as above. Two rows with the same values, both empty included, must not overlap.
- What an upload replaces. For each role, everything already stored between the role's earliest start and latest end is replaced, for every language and region, including stretches inside that span that no row covers. Demand outside the span is untouched. A row covers its start up to, but not including, its end.
- Replacing a whole week. Because the span runs from the first row to the last, a week of weekday shifts leaves the weekend as it was. To replace the full week, start and end it with an untagged row of demand 0, such as Monday 00:00 to 08:00 and Friday 16:00 to the next Monday 00:00. The weeknight gaps in between are cleared because they fall inside the span.
- Unknown columns and bad values. A column the role doesn't take is ignored and listed in
ignoredColumnsin the response. A value that is not in the column's list rejects the whole request, nothing is saved, and the error names the row:Row 3: "de" is not a value of the language column (expected one of: en, fr).