> ## Documentation Index
> Fetch the complete documentation index at: https://developers.kardinal.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Skills and assignments

> Reserve an order for the resources that can do it with skills, prefer one resource without requiring it, bar one resource from a stop, and check in the solution that the engine kept these rules.

A skill is a label for something a resource can do or carries. A gas certification and a refrigerated body are two examples. You decide the labels, and the engine only compares them as text. The order carries the requirement, in `requiredSkills`. The resource carries what it has, in `skills`. The engine gives the order only to a resource whose `skills` hold every skill the order requires.

This guide covers `requiredSkills` and `skills`, the softer `preferredStopTags`, and the `forbiddenAssignment` constraint. Each section states one rule, with a link to the API reference where the exact behavior is written. The last section shows how to check a solution against these rules.

## Reserve an order for qualified resources

A boiler repair needs a technician with a gas certification. Write the requirement on the order, and give the certification to the technicians who hold it:

```json theme={null}
{
  "resources": [
    { "id": "tech-certified", "skills": ["gas-certified"] },
    { "id": "tech-plain" }
  ],
  "orders": [
    { "id": "order-boiler", "requiredSkills": ["gas-certified"], "stops": [ { "...": "..." } ] }
  ]
}
```

The engine gives the boiler order to `tech-certified`, even when `tech-plain` lives next to the customer. In the sandbox, a certified technician 7 km away served the job, and the uncertified technician next door did not.

When no resource qualifies, the engine doesn't fail. It leaves the stop out of every tour and lists it in `unaffectedStopIds`. The plan is still `optimized`, and the solution reports no violation. You need to read `unaffectedStopIds` to see the gap. See [Checking the solution](#checking-the-solution).

See [`Order.requiredSkills`](https://developers.kardinal.ai/api-reference/plan/create-a-plan#body-orders) and [`Resource.skills`](https://developers.kardinal.ai/api-reference/plan/create-a-plan#body-resources) for the rule.

## Vehicles with equipment

The equipment of a vehicle works like the certification of a person. Say that only one van has a refrigerated body. Give that van the skill, and require the skill on the orders that need cold:

```json theme={null}
{
  "resources": [
    { "id": "van-refrigerated", "skills": ["refrigerated"] },
    { "id": "van-ambient" }
  ],
  "orders": [
    { "id": "order-fresh", "requiredSkills": ["refrigerated"], "stops": [ { "...": "..." } ] },
    { "id": "order-dry", "stops": [ { "...": "..." } ] }
  ]
}
```

The fresh order goes to `van-refrigerated`, even when `van-ambient` starts closer. In the sandbox, the engine sent the fresh order to the refrigerated van although the ambient van was next to it.

A requirement doesn't reserve the resource. An order without `requiredSkills` can go to any resource, including one that has skills. In the same sandbox test, the refrigerated van also took the dry order, and the ambient van stayed unused. The default objectives try to use as few resources as possible before they shorten the distance.

## How skills match

* **Every skill is needed.** An order that requires `gas-certified` and `electrical-certified` goes only to a resource that has both. A resource with only one of them can't serve it.
* **The match is exact.** The text must be identical, case included. A resource with `Gas-Certified` doesn't serve an order that requires `gas-certified`. Choose one naming habit, such as lowercase words joined by hyphens, so that every system that writes a plan writes the same skill the same way.
* **Tags are not skills.** A string in the `tags` of a resource doesn't satisfy `requiredSkills`. Put it in `skills`.
* **The requirement belongs to the order.** `requiredSkills` is a field of the order, not of the stop. If you write it on a stop, the API drops it and restricts nothing. Both SDKs refuse it before they send the plan. With `kardinal-ai` 1.1.0 and `@kardinal-ai/sdk` 1.1.0, the error names the field `plan.orders[0].stops[0].requiredSkills`.

## Prefer instead of require

Sometimes a resource should handle a stop when it can, but another resource may cover for it. A senior technician should take a demanding job, and a generalist can still do it. For this, tag the stop and use `preferredStopTags` on the resource, instead of `requiredSkills`:

```json theme={null}
{
  "resources": [
    { "id": "technician-senior", "preferredStopTags": ["skill:senior-preferred"] }
  ],
  "orders": [
    { "id": "order-1", "stops": [ { "id": "stop-1", "tags": ["skill:senior-preferred"], "...": "..." } ] }
  ],
  "objectives": ["maximizeMandatoryStops", "maximizePreferredStops", "minimizeCosts"]
}
```

The senior technician takes the tagged stop even when a generalist lives next to it. When the senior technician can't serve it, for example because the working window is too short, another resource takes the stop. The stop stays planned.

The default list of objectives already holds `maximizePreferredStops`, after `minimizeCosts` and `minimizeResources`. In the sandbox, with the default list, the senior technician took the preferred stop and the plain one together, because one resource serves both. If you write your own `objectives` list, add `maximizePreferredStops` yourself.

See [`Resource.preferredStopTags`](https://developers.kardinal.ai/api-reference/plan/create-a-plan#body-resources) for the rule.

## Barring one resource from a stop

`forbiddenAssignment` bars one resource tag from one stop tag. It fits when you know one resource that must stay away from one stop, and that resource carries the tag. For example, technician 1 is banned from a site, so the technician carries `technician:1` and the stop carries `site:restricted`:

```json theme={null}
{
  "additionalConstraints": [
    { "type": "forbiddenAssignment", "name": "no-technician-1-at-site", "resourceTag": "technician:1", "stopTag": "site:restricted" }
  ]
}
```

The engine never gives the stop to a resource that carries `technician:1`. It gives the stop to another resource. When no other resource can serve it, the stop stays unplanned, with no violation.

The rule bars only the resources that carry the tag. A technician that you add later without the tag can serve the site, and the engine doesn't warn you. See the `Modelling pitfall` of [`forbiddenAssignment`](https://developers.kardinal.ai/api-reference/plan/create-a-plan#body-additional-constraints) in the API reference.

For a rule about a kind of resource, write a requirement instead of a ban. Take a city that forbids trucks over 7.5 t in its old town:

* **As a ban**, you tag every heavy truck `heavy-truck`, tag the old-town stops, and add the `forbiddenAssignment`. A heavy truck added later without its tag can serve the old town. The mistake is silent.
* **As a requirement**, you give `small-vehicle` to the vehicles allowed in the old town, and require it on the old-town orders. A vehicle added later without the skill can't serve them. The mistake is visible: if no other vehicle has the skill, the stop stays in `unaffectedStopIds` until you add it.

A requirement fails visibly when a label is missing. A ban fails silently. Choose the requirement when the fleet changes.

## Pinning a stop to one resource

To pin a stop to one resource, see the pattern "Isolate" in [Manual assignment and supervision](https://developers.kardinal.ai/guides/manual-assignment-and-supervision.md).

A pinned stop is an exception to the rules of this guide. Say you pin a stop in `state.assignments` on a resource that lacks the skill. The engine keeps the stop there. The plan is still `optimized`, and no stop is unplanned. The tour of that resource has `isValid: false` and a violation of type `skills`, with the list `lackingSkills`. In the sandbox, this held for the three `mode` and `status` pairs tested.

A stop pinned on a resource barred by a `forbiddenAssignment` stays too. The tour then has a violation of type `forbiddenAssignment`.

## Checking the solution

The engine doesn't report a missing skill as an error. Read these fields of the solution:

* **`unaffectedStopIds`** lists the stops that no resource could serve, for example because none had the skill.
* **`tours[].isValid`** is `false` when a hard rule is broken, for example a pinned stop on a resource without the skill.
* **`tours[].violations`** says which rule. A violation of type `skills` lists the `lackingSkills`. A violation of type `forbiddenAssignment` lists the barred pair.

### Example plan

This plan has two technicians. `tech-certified` holds the skill `gas-certified`, and `tech-plain` holds no skill. Each of the three orders shows one rule. The boiler needs a gas certification and sits next to `tech-plain`. The meter has no requirement. The panel needs an electrical certification that nobody holds.

```json theme={null}
{
  "tz": "Europe/Paris",
  "maxOptimizationDuration": "PT5S",
  "resources": [
    {
      "id": "tech-certified",
      "vehicleProfile": { "type": "car" },
      "workingTimeWindow": { "begin": "2026-11-02 08:00", "end": "2026-11-02 18:00" },
      "departure": { "lon": 2.4, "lat": 48.85 },
      "arrival": { "lon": 2.4, "lat": 48.85 },
      "skills": ["gas-certified"]
    },
    {
      "id": "tech-plain",
      "vehicleProfile": { "type": "car" },
      "workingTimeWindow": { "begin": "2026-11-02 08:00", "end": "2026-11-02 18:00" },
      "departure": { "lon": 2.3, "lat": 48.85 },
      "arrival": { "lon": 2.3, "lat": 48.85 }
    }
  ],
  "orders": [
    {
      "id": "order-boiler",
      "requiredSkills": ["gas-certified"],
      "stops": [
        { "type": "single", "id": "stop-boiler", "kind": "delivery", "operationDuration": "PT30M", "position": { "lon": 2.303, "lat": 48.85 } }
      ]
    },
    {
      "id": "order-meter",
      "stops": [
        { "type": "single", "id": "stop-meter", "kind": "delivery", "operationDuration": "PT30M", "position": { "lon": 2.403, "lat": 48.85 } }
      ]
    },
    {
      "id": "order-panel",
      "requiredSkills": ["electrical-certified"],
      "stops": [
        { "type": "single", "id": "stop-panel", "kind": "delivery", "operationDuration": "PT30M", "position": { "lon": 2.353, "lat": 48.86 } }
      ]
    }
  ]
}
```

The same plan with the SDKs:

```python Python theme={null}
from kardinal.routing import models
from kardinal.routing.plans import Order, PlanCreate


def technician(id: str, lon: float, skills: list[str]) -> models.Resource:
    home = models.Position(lon=lon, lat=48.85)
    return models.Resource(
        id=id,
        vehicleProfile=models.VehicleProfileCar(type="car"),
        workingTimeWindow=models.TimeWindow(begin="2026-11-02 08:00", end="2026-11-02 18:00"),
        departure=home,
        arrival=home,
        skills=skills,
    )


def job(id: str, lon: float, lat: float, required_skills: list[str]) -> Order:
    stop = models.SingleStop(
        type="single",
        id=f"stop-{id}",
        kind="delivery",
        position=models.Position(lon=lon, lat=lat),
        operationDuration="PT30M",
    )
    return Order(id=f"order-{id}", stops=[stop], requiredSkills=required_skills)


plan = PlanCreate(
    tz="Europe/Paris",
    maxOptimizationDuration="PT5S",
    resources=[technician("tech-certified", 2.40, ["gas-certified"]), technician("tech-plain", 2.30, [])],
    orders=[
        job("boiler", 2.303, 48.85, ["gas-certified"]),
        job("meter", 2.403, 48.85, []),
        job("panel", 2.353, 48.86, ["electrical-certified"]),
    ],
)
```

```ts TypeScript theme={null}
import type { PlanCreateInput } from "@kardinal-ai/sdk";

function technician(id: string, lon: number, skills: string[]) {
  const home = { lon, lat: 48.85 };
  return {
    id,
    priority: 0,
    vehicleProfile: { type: "car" as const },
    workingTimeWindow: { begin: "2026-11-02 08:00", end: "2026-11-02 18:00" },
    departure: home,
    arrival: home,
    skills,
  };
}

function job(id: string, lon: number, lat: number, requiredSkills: string[]) {
  return {
    id: `order-${id}`,
    priority: 0,
    requiredSkills,
    stops: [
      {
        type: "single" as const,
        id: `stop-${id}`,
        kind: "delivery" as const,
        position: { lon, lat },
        operationDuration: "PT30M",
      },
    ],
  };
}

export const plan: PlanCreateInput = {
  tz: "Europe/Paris",
  maxOptimizationDuration: "PT5S",
  resources: [technician("tech-certified", 2.4, ["gas-certified"]), technician("tech-plain", 2.3, [])],
  orders: [
    job("boiler", 2.303, 48.85, ["gas-certified"]),
    job("meter", 2.403, 48.85, []),
    job("panel", 2.353, 48.86, ["electrical-certified"]),
  ],
};
```

### What the solution shows

The sandbox returns this for the plan, with the offset of `Europe/Paris` and with seconds. The other fields are left out.

```json theme={null}
{
  "unaffectedStopIds": ["stop-panel"],
  "unusedResourceIds": ["tech-plain"],
  "tours": [
    {
      "resourceId": "tech-certified",
      "isValid": true,
      "wayPoints": [
        { "type": "stop", "stopId": "stop-meter", "beginTime": "2026-11-02T08:00:16+01:00", "departureTime": "2026-11-02T08:30:16+01:00" },
        { "type": "stop", "stopId": "stop-boiler", "beginTime": "2026-11-02T08:39:03+01:00", "departureTime": "2026-11-02T09:09:03+01:00" }
      ]
    }
  ]
}
```

* The boiler goes to `tech-certified`, the only technician with the skill, although `tech-plain` lives next to it.
* The meter has no requirement. `tech-certified` serves it too, and `tech-plain` stays unused. The default objectives use as few resources as they can.
* The panel is in `unaffectedStopIds`. Nobody holds `electrical-certified`, so no tour has it. The tour is valid and the plan is `optimized`: nothing in the solution calls this a failure.

### A check in code

These snippets read the solution you got for the example plan. They list the unplanned stops. Then they print, for each tour, if it is valid and which skills it lacks. A violation of type `forbiddenAssignment` is printed too.

```python Python theme={null}
for stop_id in solution.unaffectedStopIds or []:
    print(stop_id, "was not planned")

for tour in solution.tours:
    resource_id = tour.resourceId.root
    print(resource_id, "valid" if tour.isValid else "NOT VALID")
    for violation in tour.violations or []:
        violation = violation.root
        if violation.type == "skills":
            print(" ", resource_id, "lacks", [s.root for s in violation.lackingSkills])
        elif violation.type == "forbiddenAssignment":
            print(" ", resource_id, "is barred by a forbiddenAssignment")
```

```ts TypeScript theme={null}
import type { WaitedSolution } from "@kardinal-ai/sdk";

export function check(solution: WaitedSolution): void {
  for (const stopId of solution.unaffectedStopIds ?? []) console.log(stopId, "was not planned");

  for (const tour of solution.tours ?? []) {
    console.log(tour.resourceId, tour.isValid ? "valid" : "NOT VALID");
    for (const violation of tour.violations ?? []) {
      if (violation.type === "skills") {
        console.log(" ", tour.resourceId, "lacks", violation.lackingSkills);
      } else if (violation.type === "forbiddenAssignment") {
        console.log(" ", tour.resourceId, "is barred by a forbiddenAssignment");
      }
    }
  }
}
```

For the example plan, both print the same two lines:

```text theme={null}
stop-panel was not planned
tech-certified valid
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.