[Canonical page](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/suggesting-assignments/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Suggesting assignments

This guide shows how to let Planning Engine pick who works on a task through the raw WebAssembly exports. It covers filling open positions, fixing assignments that no longer fit, moving work off overloaded people, reading the proposal and committing it.

## How auto-assignment works

[`suggestAssignments(slots_json, options_json)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) on a solved [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) takes a list of [**slots**](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/slot.md>) and returns a proposal. It's read-only: the project doesn't change, and you decide what to commit.

A slot is one position on one task: "task C needs one person, and dev1 or dev2 can do it". It holds the task, the list of eligible resources (`allowed`) and, when someone already fills it, the assignment that does. `suggestAssignments` doesn't know about skills. If you model skills, the [`slots`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) export turns workers and requirements into slots for you (see [Skills and requirements](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/skills-and-requirements.md>)).

Every slot is in one of three states, judged from its current resource:

| State | Meaning | `targets` value | What the call does |
|---|---|---|---|
| empty | no assignment | `"empty"` | fills it with the cheapest eligible resource |
| broken | held by a resource outside `allowed` | `"broken"` | moves it to the cheapest eligible resource |
| valid | held by a resource in `allowed` | `"valid"` | moves it only if another resource is strictly cheaper |

The `targets` option says which states to act on. The default is `["empty"]`: fill open positions, leave the rest alone.

The call is greedy. It visits slots one at a time and tries every eligible resource on a working copy of the project. Each try is scheduled and scored the same way [`evaluate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md>) scores a candidate: finish delay plus overload plus a heavy penalty for conflicts. The cheapest resource wins, and later slots build on the earlier picks.

The envelope's [result](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md#suggestresult>) has two main parts. `changes` is a [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) payload you can send to [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) unchanged. `steps` is a log of every decision, with the cost before and after, so a UI can explain each pick.

`suggestAssignments` never removes an assignment. It can add one (fill) or change the resource of one (repair, rebalance). A slot it can't fix is reported, and you decide what to do with it.

## Slots

A [`Slot`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/slot.md>) is a JSON object with `id`, `task` and `allowed`. The order of `allowed` is the tie-break: when two resources cost the same, the earlier one wins. A new assignment gets `value` units, `1` by default.

The slot id becomes the id of the assignment that fills it. On the next run, pass that id back in the slot's `assignment` field, so the slot counts as filled and isn't filled twice.

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  // dev1 works on A and B at the same time.
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  // Task C needs one person: dev1 or dev2.
  const slots = [{ id: "sC", task: "C", allowed: ["dev1", "dev2"] }];
  const result = unwrap(project.suggestAssignments(JSON.stringify(slots), "{}"));

  for (const add of result.changes.assignments.add) {
    console.log(`${add.id} on ${add.task}: ${add.resource}`);
  }
} finally {
  project.free();
}
```

This prints `sC on C: dev2`. dev1 is already double-booked on A and B, so putting C on dev1 adds two more days of overload. dev2 is free.

The proposal's `changes` looks like this:

```json
{
  "assignments": {
    "add": [{ "id": "sC", "resource": "dev2", "task": "C", "value": 1 }]
  },
  "baselineId": "1-b0605af8cba3d7c9"
}
```

To describe a filled slot, set `assignment`. The assignment must be active and on the slot's task:

```json
{ "id": "sB", "task": "B", "allowed": ["dev1", "dev2", "dev3"], "assignment": "aB" }
```

The call checks every slot before it does any work. It returns a `validation` error envelope on a duplicate slot id, an unknown, unscheduled or summary task, an unknown resource in `allowed`, a `value` that isn't a finite number above 0, or a bad `assignment`. The error `path` points at the slot, for example `slots[0].task`. An unknown field in a slot is a `parse` error. The full list is on the [`suggestAssignments` reference](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>).

If you model skills, don't build slots by hand. [`slots(pool_json, requirements_json)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) builds them from a pool of workers and task requirements, and binds existing assignments to their slots. Its result feeds `suggestAssignments` as is:

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [{ id: "C", start_date: "2026-08-10", duration: 2 }],
  resources: [{ id: "dev1" }, { id: "dev2" }],
};

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

  const pool = {
    workers: [{ resource: "dev1", skills: [{ id: "qa" }] }, { resource: "dev2" }],
  };
  const requirements = [{ id: "qa", task: "C", matches: [{ skill: "qa" }] }];

  // One slot "qa#1" on C, with dev1 as the only eligible resource.
  const slots = unwrap(project.slots(JSON.stringify(pool), JSON.stringify(requirements)));
  const result = unwrap(project.suggestAssignments(JSON.stringify(slots), "{}"));
  console.log(`${result.steps.length} step(s)`); // 1 step(s)
} finally {
  project.free();
}
```

## Options

The [options payload](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md#suggestoptions>) is a JSON object. Send `"{}"` for the defaults and set only the fields you need. Every field may also be `null`, which means the default. An unknown field, such as `mode`, fails the call with a `parse` error.

| Field | Default | Use it to |
|---|---|---|
| `targets` | `["empty"]` | choose which slot states to act on; `[]` only reports broken slots |
| `fixed` | `[]` | keep listed assignments where they are |
| `newResourcePenalty` | `0` | trade balance against headcount (see [How picks are made](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/suggesting-assignments.md#how-picks-are-made>)) |
| `passes` | `1` | rebalance valid slots more than once |
| `level` | `false` | score each candidate after leveling |
| `clearFirst` | `false` | score against a copy with stored leveling delays cleared |
| `baselineId` | absent | fail with `stale-baseline` if the project changed |

All fields are on the [`suggestAssignments` reference](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>).

### Rebalancing filled slots

Add `"valid"` to `targets` to move work off overloaded people. Rebalancing runs after filling and repairing, so it also sees the slots that were just filled.

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  const slots = [
    { id: "sC", task: "C", allowed: ["dev1", "dev2"] },
    { id: "sB", task: "B", allowed: ["dev1", "dev2", "dev3"], assignment: "aB" },
  ];
  const options = { targets: ["empty", "valid"] };
  const result = unwrap(
    project.suggestAssignments(JSON.stringify(slots), JSON.stringify(options)),
  );

  for (const update of result.changes.assignments.update) {
    console.log(`${update.id}: ${update.resource}`); // aB: dev3
  }
} finally {
  project.free();
}
```

The call fills `sC` with dev2, then moves `aB` from dev1 to dev3. dev2 isn't a good pick for B anymore, because it now works on C. The proposal holds one add (`sC`) and one update (`aB` → dev3).

A valid slot moves only on strict improvement: on a tie, the current resource stays. With `newResourcePenalty` at `0`, a valid slot is skipped when its current resource isn't overloaded during the task. One pass is often enough. When a later move makes an earlier pick worse, raise `passes`. The loop stops early after a pass that moves nothing.

### Keeping people in place

List assignment ids in `fixed` to keep them out of every pass, whatever the load. With the same project and slots as above, change only the options:

```js
const options = {
  targets: ["empty", "valid"],
  fixed: ["aB"], // dev1 stays on B
};
const result = unwrap(
  project.suggestAssignments(JSON.stringify(slots), JSON.stringify(options)),
);
console.log(JSON.stringify(result.changes.assignments));
// {"add":[{"id":"sC","resource":"dev2","task":"C","value":1}]}
```

`sC` is still filled with dev2, but `aB` stays on dev1, so there's no `update` bucket. A fixed resource still counts as being on its task, and a fixed slot that is broken is still listed in `invalid`. An id in `fixed` must name an active assignment and appear only once, otherwise the call fails with a `validation` error at `fixed[i]`. It doesn't have to be bound to a slot.

### Repairing broken slots

A slot breaks when its resource drops out of `allowed`, for example after a requirement or a skill grade changed. Run with `targets: []` first to only check, then with `"broken"` to fix:

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  // B now needs dev2 or dev3, but dev1 holds it.
  const slots = JSON.stringify([
    { id: "sB", task: "B", allowed: ["dev2", "dev3"], assignment: "aB" },
  ]);

  const report = unwrap(project.suggestAssignments(slots, JSON.stringify({ targets: [] })));
  console.log("broken:", report.invalid); // broken: [ 'sB' ]

  const result = unwrap(
    project.suggestAssignments(slots, JSON.stringify({ targets: ["broken"] })),
  );
  for (const step of result.steps) {
    console.log(`${step.slot}: ${step.fromResource} -> ${step.toResource}`); // sB: dev1 -> dev2
  }
} finally {
  project.free();
}
```

A repair always moves the slot. It takes the cheapest eligible resource, even if keeping the current one would cost less. A broken slot is visited once, with no retry. If no eligible resource can take it, it keeps its assignment and stays in `invalid`.

### Scoring after leveling

By default each candidate is only scheduled, so overload shows up as cost. With `level`, each candidate is also leveled with those options and scored on the leveled plan, where overload turns into delay. The score then tells you how much later the project would finish with each pick. `level` takes a [`LevelingOptions`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/leveling-options.md>) object; `{}` uses the leveling defaults.

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  const slots = [{ id: "sC", task: "C", allowed: ["dev1", "dev2"] }];
  const options = { level: {} };
  const result = unwrap(
    project.suggestAssignments(JSON.stringify(slots), JSON.stringify(options)),
  );

  const finish = result.proposed.leveled?.finish.project;
  if (finish) {
    console.log(`leveled finish: ${finish.date} (${finish.delta} days later)`);
  }
} finally {
  project.free();
}
```

`sC` goes to dev2. The code prints `leveled finish: 2026-08-14 00:00 (2 days later)`: leveling still has to separate A and B on dev1.

Each pick builds on the scheduled copy, not the leveled one, so leveling delays don't add up across picks. Every candidate runs a full level, so keep leveled scoring for small sets of slots.

If the project holds delays from an earlier [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) run, those delays can hide overload: the rebalance skip rule then sees nothing to fix. Set `clearFirst: true` to score against a copy with the delays and pauses cleared. `changes` then starts with a `tasks.update` entry `{ "id": …, "leveling_delay": 0, "pauses": [] }` for each cleared task, so committing it reproduces the cleared state.

## How picks are made

Slots are visited in a fixed order, set once from the accepted plan: task start, then task order in the model, then the order of the slots you passed. Picks that move dates don't change the order. The order matters, because the result is greedy: an early slot takes the best resource, and later slots work around it.

The call runs in passes:

1. Pass 0 fills empty slots and repairs broken ones, if those states are targeted.
2. Passes 1 to `passes` rebalance every valid slot, if `"valid"` is targeted.

For each slot it tries every resource in `allowed`, except resources already on the task (a task never gets the same person twice) and duplicates. It schedules each try on a copy of the working state and computes its cost:

```text
cost = evaluate cost against the accepted plan
     + newResourcePenalty × (people in use now − people in use in the accepted plan)
```

The evaluate cost is finish delay plus overload plus 1000 per conflict or cycle; see [Evaluating alternatives](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/evaluating-alternatives.md>). It's always measured against the accepted plan, never against the previous pick.

"People in use" counts the resources in any slot's `allowed`, plus the current holders of filled slots, that have at least one active assignment. Once someone is brought in, later slots can use them for free. Moving someone's last assignment away lowers the cost.

`newResourcePenalty` is the one lever between balance and headcount. At `0` the call spreads work to idle people. A large value keeps work on the people already busy, as long as their extra overload costs less than the penalty:

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  const slots = JSON.stringify([
    { id: "sC", task: "C", allowed: ["dev1", "dev2"] },
    { id: "sB", task: "B", allowed: ["dev1", "dev2", "dev3"], assignment: "aB" },
  ]);

  for (const penalty of [0, 0.5, 5]) {
    const options = { targets: ["empty", "valid"], newResourcePenalty: penalty };
    const result = unwrap(project.suggestAssignments(slots, JSON.stringify(options)));
    console.log(
      `penalty ${penalty}: ${result.proposed.resources} in use, cost ${result.proposed.cost}`,
    );
  }
} finally {
  project.free();
}
```

```text
penalty 0: 3 in use, cost 0
penalty 0.5: 3 in use, cost 1
penalty 5: 1 in use, cost 4
```

At `0.5`, two new people cost 1, and removing four days of overload is worth more. At `5`, one new person would cost more than the two extra days of overload on dev1, so C goes to dev1 and nobody else is brought in. With a penalty above `0`, valid slots aren't skipped when their holder isn't overloaded: freeing a person is a gain on its own.

A negative or non-finite penalty fails the call with a `validation` error. So does `passes` below 1, a duplicate in `targets`, or an unknown `targets` value (that one has `path: null`).

A candidate that can't be applied or scheduled is dropped. An empty slot with no candidate left goes to `unfilled`.

## Reading the proposal

The [result payload](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md#suggestresult>) has everything a UI needs to show and explain the proposal:

- [`changes`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>): the net edit. One add per filled slot, one update per moved assignment (even after several passes), and the `clearFirst` clears. Its `baselineId` is set. Empty buckets and unset fields are left out, never `null`.
- [`steps`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md#suggeststep>): every decision that changed the working state, in order.
- `unfilled`: targeted empty slots left empty.
- `invalid`: slots whose resource is outside `allowed` at the end, whatever `targets` was.
- `baseline` and `proposed`: cost and people in use before and after. `proposed` also carries the scheduled [`Metrics`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/metrics.md>) and, with `level`, the leveled ones.

A step has no kind field. Read the kind from `fromResource` and `pass`: `fromResource: null` is a fill, `pass: 0` with a resource is a repair, and `pass` 1 or higher is a rebalance.

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  const slots = [
    { id: "sC", task: "C", allowed: ["dev1", "dev2"] },
    { id: "sB", task: "B", allowed: ["dev1", "dev2", "dev3"], assignment: "aB" },
  ];
  const options = { targets: ["empty", "valid"] };
  const result = unwrap(
    project.suggestAssignments(JSON.stringify(slots), JSON.stringify(options)),
  );

  console.log(`cost ${result.baseline.cost} -> ${result.proposed.cost}`);
  for (const step of result.steps) {
    let action;
    if (step.fromResource === null) action = "fill";
    else if (step.pass === 0) action = `repair from ${step.fromResource}`;
    else action = `rebalance from ${step.fromResource} (pass ${step.pass})`;
    console.log(
      `${step.slot} on ${step.task}: ${action} to ${step.toResource}, ` +
        `cost ${step.cost.before} -> ${step.cost.after}`,
    );
  }
} finally {
  project.free();
}
```

```text
cost 2 -> 0
sC on C: fill to dev2, cost 2 -> 2
sB on B: rebalance from dev1 (pass 1) to dev3, cost 2 -> 0
```

The step costs form a chain: the first `before` is `baseline.cost`, and each `before` is the previous step's `after`. The baseline costs 2 because dev1 is already overloaded for two days. Filling C doesn't make that worse, and moving B to dev3 removes it.

`proposed.scheduled` is the full `Metrics` of the final state, with finish, overload per resource, conflicts and cycles. The steps above come from this envelope result (trimmed):

```json
{
  "baseline": { "cost": 2, "resources": 1 },
  "baselineId": "1-b0605af8cba3d7c9",
  "invalid": [],
  "proposed": {
    "cost": 0,
    "resources": 3,
    "scheduled": { "cost": 0, "finish": { "project": { "date": "2026-08-12 00:00", "delta": 0 }, "tasks": [] } }
  },
  "steps": [
    {
      "cost": { "after": 2, "before": 2 },
      "fromResource": null,
      "pass": 0,
      "slot": "sC",
      "task": "C",
      "toResource": "dev2"
    },
    {
      "cost": { "after": 0, "before": 2 },
      "fromResource": "dev1",
      "pass": 1,
      "slot": "sB",
      "task": "B",
      "toResource": "dev3"
    }
  ],
  "unfilled": []
}
```

See the [`suggestAssignments` reference](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) and [`Metrics`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/metrics.md>).

## Committing

Send `result.changes` to [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) as is, then reschedule with [`solve(undefined, …)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>). If you scored with `level`, call [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) with the same options instead.

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  config: { use_calendars: false },
  tasks: [
    { id: "A", start_date: "2026-08-10", duration: 2 },
    { id: "B", start_date: "2026-08-10", duration: 2 },
    { id: "C", start_date: "2026-08-10", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }, { id: "dev3" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

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

  const options = JSON.stringify({ targets: ["empty", "valid"] });
  const slots = [
    { id: "sC", task: "C", allowed: ["dev1", "dev2"] },
    { id: "sB", task: "B", allowed: ["dev1", "dev2", "dev3"], assignment: "aB" },
  ];
  const result = unwrap(project.suggestAssignments(JSON.stringify(slots), options));

  const report = unwrap(project.apply(JSON.stringify(result.changes)));
  if (report.rejected.length > 0) {
    throw new Error("proposal rejected, resync the project");
  }
  unwrap(project.solve(undefined, "{}"));

  // Next run: sC is now the id of the assignment on C.
  const next = [
    { id: "sC", task: "C", allowed: ["dev1", "dev2"], assignment: "sC" },
    { id: "sB", task: "B", allowed: ["dev1", "dev2", "dev3"], assignment: "aB" },
  ];
  const again = unwrap(project.suggestAssignments(JSON.stringify(next), options));
  console.log(`${again.steps.length} step(s)`); // 0 step(s): nothing left to improve
} finally {
  project.free();
}
```

`changes.baselineId` names the plan the proposal was built on. If the project changed in between, `apply` returns a `project` error with code `stale-baseline` instead of committing an outdated proposal. A rejected operation sets `needsResync` in the [`ApplyReport`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>); see [Recovering from rejected changes](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/recover-from-rejected-changes.md>) and [Error handling](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md>).

The new assignment takes the slot id, so the slot keeps its binding after the commit. On the next run, pass that id in the slot's `assignment`, as above. [`slots`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) binds slots this way for you. A slot id that is already the id of another assignment fails the call:

```json
{
  "ok": false,
  "error": {
    "kind": "validation",
    "code": null,
    "message": "slot id \"sC\" equals an existing assignment id",
    "path": "slots[0].id"
  }
}
```

Without that check, every add for the slot would be rejected as a duplicate id, and the slot would stay unfilled without a reason.

You can also commit part of a proposal. `changes` is a plain [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) payload, so drop the adds or updates the user declines before you call `apply`.

The committed plan matches `proposed` when `config.gap_behavior` is `"compress"`. With the default `"preserve"`, it can differ slightly. The call reschedules after each pick, while the commit reschedules once, and preserved dates depend on the path taken. Treat `proposed` as an estimate in that mode.

`suggestAssignments` runs one schedule (and one level, with `level`) per slot and candidate. Like every export, it's synchronous and blocks the calling thread. On large projects, run it in a Web Worker or a worker thread; see [Run in a worker](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/run-in-a-worker.md>).
