Skills and requirements#

This guide shows how to staff tasks by skill: describe who can do what, state what each task needs, and get slots that suggestAssignments fills. It also covers how earlier assignments stay attached to their requirements from one run to the next.

How the skill layer works#

The scheduling engine doesn't know about skills. Auto-assignment works on slots: one place for one person on one task, with a list of resources allowed to take it. The slots method of a JsonProject builds those slots from two JSON strings that describe workers and their skills: project.slots(pool_json, requirements_json).

Three inputs go into it:

  • The accepted state of the solved project behind the handle. slots reads its scheduled leaf tasks, resources and active assignments.
  • A Pool (pool_json): an object with a workers array. Each worker is a resource id plus an optional skills array of {id, value} entries. A skill value is 0/1 for yes-or-no skills, or a grade (1 = junior, 2 = senior, and so on). Leave value out and it's 1. Worker order is the tie-break preference.
  • A Requirement[] array (requirements_json). A requirement says "task task needs count people who satisfy all of these matches, each booked at units".

slots returns an envelope whose result is a Slot array. Send it to suggestAssignments unchanged, as JSON.stringify(result):

  • Each requirement becomes count slots with ids "<requirement id>#1", "#2" and so on, in requirement order.
  • allowed lists the pool workers that pass every match of the requirement, in pool order.
  • value is the requirement's units: the rate a new assignment books.
  • assignment names the existing assignment that already fills the slot. It's left out when the slot is empty. See Binding existing assignments.

A slot's id is also the id of the assignment that fills it. After you commit a proposal, the task carries assignments named be#1, be#2, and the next slots call finds them by id. That's what keeps staffing stable across runs. It also means a requirement id belongs to its task: reuse be on another task after a commit and slots refuses it.

Skills, pools and requirements aren't stored in ProjectData. You send them on every call, so they can come from wherever your app keeps them. slots is read-only: it never changes the project.

Pool and requirement payloads are strict. An unknown field is a parse error, and null on an optional field means its default.

Pools and skills#

Build a pool, describe what each task needs, and turn both into slots:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function call(json) {
  const response = JSON.parse(json);
  if (!response.ok) {
    throw new Error(`${response.error.kind}: ${response.error.message} (at ${response.error.path})`);
  }
  return response.result;
}

// Two 2-day tasks starting Monday 2026-08-10.
const data = {
  tasks: [
    { id: "api", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "ui", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "ann" }, { id: "bob" }, { id: "cat" }],
};

// The pool: who can do what. Order is the tie-break preference.
// A skill without `value` counts as 1.
const pool = {
  workers: [
    { resource: "ann", skills: [{ id: "backend" }, { id: "frontend" }] },
    { resource: "bob", skills: [{ id: "backend" }] },
    { resource: "cat", skills: [{ id: "frontend" }] },
  ],
};
// What each task needs. `{ skill }` alone means "has the skill".
const requirements = [
  { id: "be", task: "api", matches: [{ skill: "backend" }] },
  { id: "fe", task: "ui", matches: [{ skill: "frontend" }] },
];

const project = new JsonProject("{}");
try {
  call(project.solve(JSON.stringify(data), "{}"));
  const slots = call(project.slots(JSON.stringify(pool), JSON.stringify(requirements)));
  for (const slot of slots) {
    console.log(JSON.stringify(slot));
  }
} finally {
  project.free();
}

Output:

{"allowed":["ann","bob"],"id":"be#1","task":"api","value":1}
{"allowed":["ann","cat"],"id":"fe#1","task":"ui","value":1}

Ann has both skills, so she's eligible for both slots. Nothing is assigned yet: slots only says who may take each place. Choosing who does is the job of suggestAssignments.

A worker's resource must name a resource in the project, and each worker appears once. A worker can be in the pool without being eligible for anything, and a project resource can stay out of the pool. A resource outside the pool is never offered for a slot.

Requirements and matches#

A requirement needs id, task and matches. count and units both default to 1, so a bare requirement asks for one person full-time. Set them to ask for more people or a lower rate. Each entry of matches compares a worker's skill value with a number:

op Holds when
"at_least" (default) worker value >= value
"no_more" worker value <= value
"equal" worker value == value

All three are inclusive, and value defaults to 1. That's why { "skill": "backend" } means "has the skill". A skill the worker doesn't list counts as 0, so { "skill": "qa", "op": "no_more", "value": 0 } means "not a tester". A requirement with several matches needs all of them. A requirement with an empty matches array allows every worker in the pool.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function call(json) {
  const response = JSON.parse(json);
  if (!response.ok) {
    throw new Error(`${response.error.kind}: ${response.error.message} (at ${response.error.path})`);
  }
  return response.result;
}

const data = {
  tasks: [{ id: "release", start_date: "2026-08-10 08:00", duration: 3 }],
  resources: [{ id: "ann" }, { id: "bob" }, { id: "cat" }, { id: "dan" }],
};

// Grades: QA level 1 = junior, 2 = senior.
const pool = {
  workers: [
    { resource: "ann", skills: [{ id: "backend" }, { id: "qa", value: 1 }] },
    { resource: "bob", skills: [{ id: "backend" }, { id: "devops" }] },
    { resource: "cat", skills: [{ id: "qa", value: 2 }] },
    { resource: "dan", skills: [{ id: "qa", value: 1 }] },
  ],
};

const requirements = [
  // Two half-time backend developers.
  { id: "be", task: "release", matches: [{ skill: "backend" }], count: 2, units: 0.5 },
  // A senior tester: grade 2 or higher.
  { id: "qa", task: "release", matches: [{ skill: "qa", value: 2 }] },
  // A junior tester: exactly grade 1.
  { id: "qa-jr", task: "release", matches: [{ skill: "qa", op: "equal", value: 1 }] },
  // A backend developer who also does devops: every match must hold.
  { id: "ops", task: "release", matches: [{ skill: "backend" }, { skill: "devops" }] },
  // Anyone who is not a tester: a missing skill counts as 0.
  { id: "review", task: "release", matches: [{ skill: "qa", op: "no_more", value: 0 }] },
];

const project = new JsonProject("{}");
try {
  call(project.solve(JSON.stringify(data), "{}"));
  const slots = call(project.slots(JSON.stringify(pool), JSON.stringify(requirements)));
  for (const slot of slots) {
    console.log(`${slot.id}: value ${slot.value}, allowed ${JSON.stringify(slot.allowed)}`);
  }
} finally {
  project.free();
}

Output:

be#1: value 0.5, allowed ["ann","bob"]
be#2: value 0.5, allowed ["ann","bob"]
qa#1: value 1, allowed ["cat"]
qa-jr#1: value 1, allowed ["ann","dan"]
ops#1: value 1, allowed ["bob"]
review#1: value 1, allowed ["bob"]

count: 2 gives two slots, be#1 and be#2, each booking half-time. Grades turn "senior tester" and "junior tester" into at_least and equal matches on the same skill. review excludes Ann, Cat and Dan because each of them has a QA grade above 0.

A slot list can allow the same person on several slots of one task. suggestAssignments never puts one resource on a task twice, so Ann can't end up in both be#1 and be#2.

Binding existing assignments#

When the task already has active assignments, slots attaches them to slots, so a filled place isn't offered again. Binding runs per task, over all requirements on that task, in three steps:

  1. Slot id. An assignment whose id is one of the requirement's slot ids (be#2) binds to that slot. This is how assignments created by an earlier suggestAssignments run find their way back. It binds even when the resource no longer matches. The slot is then broken.
  2. Explicit list. An assignments array on a requirement replaces automatic binding for it. Listed entries take the remaining slots in order, and leftover slots stay empty. "assignments": [] leaves every slot empty. Leave the field out (or send null) for automatic binding.
  3. Matching. The task's remaining assignments go to the empty slots of requirements without a list. An assignment can go to a slot only when its resource is in the slot's allowed. The engine runs a maximum bipartite matching in input order. It fills as many slots as possible instead of just giving each slot the first fit.

Here Ann (backend and frontend) and Bob (backend only) already work on api under ids the skill layer didn't create:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function call(json) {
  const response = JSON.parse(json);
  if (!response.ok) {
    throw new Error(`${response.error.kind}: ${response.error.message} (at ${response.error.path})`);
  }
  return response.result;
}

function print(label, slots) {
  const bound = slots.map((s) => (s.assignment ? `${s.id} <- ${s.assignment}` : `${s.id} empty`));
  console.log(`${label}: ${JSON.stringify(bound)}`);
}

// Ann and Bob already work on `api`, under ids the skill layer didn't create.
const data = {
  tasks: [{ id: "api", start_date: "2026-08-10 08:00", duration: 2 }],
  resources: [{ id: "ann" }, { id: "bob" }],
  assignments: [
    { id: "a1", task: "api", resource: "ann", value: 1 },
    { id: "a2", task: "api", resource: "bob", value: 1 },
  ],
};
const pool = JSON.stringify({
  workers: [
    { resource: "ann", skills: [{ id: "backend" }, { id: "frontend" }] },
    { resource: "bob", skills: [{ id: "backend" }] },
  ],
});
const backend = { id: "be", task: "api", matches: [{ skill: "backend" }] };
const frontend = { id: "fe", task: "api", matches: [{ skill: "frontend" }] };

const project = new JsonProject("{}");
try {
  call(project.solve(JSON.stringify(data), "{}"));

  // Automatic: a maximum matching, so Bob takes backend and Ann frontend.
  print("matching", call(project.slots(pool, JSON.stringify([backend, frontend]))));

  // Explicit list: `be` takes a1, whatever the matching would do.
  const pinned = { ...backend, count: 2, assignments: ["a1"] };
  print("explicit", call(project.slots(pool, JSON.stringify([pinned, frontend]))));

  // Empty list: every slot of `be` starts empty.
  const fresh = { ...backend, assignments: [] };
  print("empty list", call(project.slots(pool, JSON.stringify([fresh, frontend]))));
} finally {
  project.free();
}

Output:

matching: ["be#1 <- a2","fe#1 <- a1"]
explicit: ["be#1 <- a1","be#2 empty","fe#1 empty"]
empty list: ["be#1 empty","fe#1 <- a1"]

A greedy pass would give be to Ann, the first backend assignment, and leave fe empty. The matching gives backend to Bob so Ann can cover frontend. The explicit list pins a1 to be#1. Bob's a2 can't fill fe#1, so that slot stays empty. With an empty list, be starts empty and Ann's assignment goes to fe.

An assignment that ends up in no slot stays on the task. It still blocks its resource for the task's other slots, and suggestAssignments never repairs or moves it.

slots checks explicit lists up front:

  • At most count entries, each an active assignment on the requirement's task, and each bound once.
  • If an assignment carries one of the requirement's slot ids, the list must name it. It keeps its own slot.

From slots to suggestions#

Pass the slots result straight to suggestAssignments, commit the proposal's changes with apply, and reschedule with solve. The next run with the same requirements binds the committed assignments by slot id, so the proposal stays stable. When a worker's skills change, the bound slot becomes broken, and the "broken" target repairs it:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function call(json) {
  const response = JSON.parse(json);
  if (!response.ok) {
    throw new Error(`${response.error.kind}: ${response.error.message} (at ${response.error.path})`);
  }
  return response.result;
}

// `api` and `ui` both run Monday-Tuesday. Bob already works on `api`.
const data = {
  tasks: [
    { id: "api", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "ui", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "ann" }, { id: "bob" }],
  assignments: [{ id: "a2", task: "api", resource: "bob", value: 1 }],
};
const requirements = JSON.stringify([
  { id: "be-api", task: "api", matches: [{ skill: "backend" }] },
  { id: "be-ui", task: "ui", matches: [{ skill: "backend" }] },
]);
let pool = JSON.stringify({
  workers: [
    { resource: "bob", skills: [{ id: "backend" }] },
    { resource: "ann", skills: [{ id: "backend" }] },
  ],
});
const everything = JSON.stringify({ targets: ["empty", "broken", "valid"] });

const project = new JsonProject("{}");
try {
  call(project.solve(JSON.stringify(data), "{}"));

  // Run 1: fill the empty slot.
  let slots = call(project.slots(pool, requirements));
  let proposal = call(project.suggestAssignments(JSON.stringify(slots), "{}"));
  console.log(JSON.stringify(proposal.changes.assignments));
  for (const step of proposal.steps) {
    console.log(`pass ${step.pass}: ${step.slot} -> ${step.toResource}`);
  }

  // Commit the proposal and reschedule.
  const report = call(project.apply(JSON.stringify(proposal.changes)));
  if (report.rejected.length > 0) {
    throw new Error(`${report.rejected[0].path}: ${report.rejected[0].message}`);
  }
  call(project.solve(undefined, "{}"));

  // Run 2: the same requirements bind to what run 1 committed.
  slots = call(project.slots(pool, requirements));
  for (const slot of slots) {
    console.log(`${slot.id} <- ${slot.assignment ?? "empty"}`);
  }
  proposal = call(project.suggestAssignments(JSON.stringify(slots), everything));
  console.log(`run 2: ${proposal.steps.length} steps`);

  // Run 3: Ann moved to frontend, so her slot is now broken.
  pool = JSON.stringify({
    workers: [
      { resource: "bob", skills: [{ id: "backend" }] },
      { resource: "ann", skills: [{ id: "frontend" }] },
    ],
  });
  slots = JSON.stringify(call(project.slots(pool, requirements)));
  const check = call(project.suggestAssignments(slots, '{"targets":[]}'));
  console.log(`invalid: ${JSON.stringify(check.invalid)}`);
  proposal = call(project.suggestAssignments(slots, everything));
  for (const step of proposal.steps) {
    console.log(`pass ${step.pass}: ${step.slot} ${step.fromResource} -> ${step.toResource}`);
  }
} finally {
  project.free();
}

Output:

{"add":[{"id":"be-ui#1","resource":"ann","task":"ui","value":1}]}
pass 0: be-ui#1 -> ann
be-api#1 <- a2
be-ui#1 <- be-ui#1
run 2: 0 steps
invalid: ["be-ui#1"]
pass 0: be-ui#1 ann -> bob

Run 1 fills be-ui#1 with Ann. Bob comes first in the pool, but he's already busy on api. The committed assignment gets the slot's id, be-ui#1. In run 2 it binds back to its slot, and with every target on there's nothing to improve. In run 3 Ann no longer has the backend skill. {"targets": []} only reports the broken slot in invalid. The full run repairs it by moving the assignment to Bob.

Some things to keep in mind:

  • slots needs a solved, clean project. After apply, call solve(undefined, …) before you build slots again.
  • suggestAssignments checks the slots again: unknown tasks or resources, duplicate slot ids, and a slot id that is already an assignment on another task are errors. Slots from slots pass these checks, unless the project changed in between.
  • Targets, fixed assignments, newResourcePenalty, leveled scoring and the step log are covered in Suggesting assignments.
  • To keep a person on a slot whatever the load, add the slot's assignment id to fixed in the suggestAssignments options.

Errors#

slots never throws for bad input. It returns the same error envelope as every other export (see Error handling):

  • kind: "project", code: "no-baseline": the project is unsolved, or it has unsolved edits after apply. slots takes no baselineId, so there's no stale-baseline case.
  • kind: "validation" with a path: bad pool or requirement input that the engine refuses.
  • kind: "validation" with path: null: a value the decoder rejects, such as an unknown op string or a non-integer count.
  • kind: "parse": malformed JSON, a missing required field (workers, or a requirement's id, task or matches), or an unknown field.
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const pool = JSON.stringify({ workers: [{ resource: "ann", skills: [{ id: "backend" }] }] });
const requirement = { id: "be", task: "api", matches: [{ skill: "backend" }] };

function report(json) {
  const response = JSON.parse(json);
  if (response.ok) {
    console.log(`${response.result.length} slots`);
  } else {
    const { kind, code, message, path } = response.error;
    console.log(JSON.stringify({ kind, code, message, path }));
  }
}

const project = new JsonProject("{}");
try {
  // Not solved yet: no accepted state to read.
  report(project.slots(pool, JSON.stringify([requirement])));

  const data = {
    tasks: [{ id: "api", start_date: "2026-08-10 08:00", duration: 2 }],
    resources: [{ id: "ann" }],
  };
  project.solve(JSON.stringify(data), "{}");

  // `count: 0` is invalid input with a path.
  report(project.slots(pool, JSON.stringify([{ ...requirement, count: 0 }])));
  // An unknown `op` fails decoding: no path.
  report(project.slots(pool, JSON.stringify([{ ...requirement, matches: [{ skill: "backend", op: "most" }] }])));
  // An unknown field is a parse error.
  report(project.slots(pool, JSON.stringify([{ ...requirement, level: 2 }])));
} finally {
  project.free();
}

Output:

{"kind":"project","code":"no-baseline","message":"no-baseline","path":null}
{"kind":"validation","code":null,"message":"must be at least 1","path":"requirements[0].count"}
{"kind":"validation","code":null,"message":"unknown skill op","path":null}
{"kind":"parse","code":null,"message":"unknown field `level`, expected one of `id`, `task`, `matches`, `count`, `units`, `assignments`","path":null}

The path of a validation error points at the offending item:

Path Reason
pool.workers[i].resource Unknown resource, or a worker listed twice
pool.workers[i].skills[j].id A skill listed twice for one worker
pool.workers[i].skills[j].value Not a finite number
requirements[i].id Duplicate requirement id, or one of its slot ids is an active assignment on another task
requirements[i].task Unknown or unscheduled task
requirements[i].count 0
requirements[i].units Not finite, or 0 or less
requirements[i].matches[j].value Not a finite number
requirements[i].assignments More entries than count, or an assignment with this requirement's slot id is left out
requirements[i].assignments[k] Unknown or inactive assignment, on another task, or bound twice (the message names the other requirement when there is one)

slots reports the first problem it finds and builds nothing. It never changes the project, so you can fix the input and call again.

slots and suggestAssignments return the same envelope, so one handler covers both calls. Branch on kind, then on code for project errors or path for input errors.