Project data#

This guide shows how to describe a plan as JSON for the Planning Engine WebAssembly module: tasks, links, ids, dates, durations, and the config switches that change how the plan is scheduled. It also lists what strict validation rejects, so your first payload is valid.

How project data works#

A plan goes in as one ProjectData JSON object with six keys: config, calendars, tasks, links, resources and assignments. Only tasks is required. The lists default to empty, and every config field has its own default.

Note

tasks is required in ProjectData; send "tasks": [] for an empty project.

ProjectData is an import format, not stored state. You pass it as the data_json string to schedule, JsonProject.solve or JsonProject.analyze. The engine validates it and builds its own normalized model. It never sends the snapshot back. Results list only the tasks that changed, as sparse updates, and you write those into your own model.

Field names are snake_case (start_date, duration, constraint_type, auto_scheduling). Project data, config and the updates entries in results use snake_case. Call options and the other result fields use camelCase (includeUnchanged, baselineId). The task type and the link type are both spelled type.

A few rules hold everywhere:

  • An id is a JSON number or string. Ids compare like JavaScript object keys, so task 1 and task "1" are the same task.
  • A date is a string: a naive local date-time at minute precision. There's no time zone, no DST and no "today".
  • duration and lag are working time, measured on calendars. leveling_delay and pauses are elapsed wall-clock time.
  • Each record must be a JSON object. Unknown fields in records and in config are ignored, so you can send tasks with text, color and other UI fields as they are.
  • null on a record field means "use the default".
  • Strict validation (the default) rejects ambiguous input with a validation error envelope and a path to the field instead of guessing. {"validation": "repair"} on a JsonProject fixes what it can and reports each fix.

How envelopes, dates and numbers are encoded is covered in Wire format. For every field and its default, see ProjectData, TaskData and SchedulingConfig.

Describing a project#

Tasks#

A leaf task needs two of start_date, end_date and duration. The engine derives the third. If you send all three, they must agree on the task's calendar.

{
  "tasks": [
    { "id": "phase", "type": "summary" },
    { "id": "design", "parent": "phase", "start_date": "2026-08-10 08:00", "duration": 3 },
    { "id": "build", "parent": "phase", "start_date": "2026-08-10 08:00", "duration": 5 },
    { "id": "release", "type": "milestone", "start_date": "2026-08-10 08:00" }
  ],
  "links": [
    { "id": "l1", "source": "design", "target": "build" },
    { "id": "l2", "source": "build", "target": "release" }
  ]
}

Send it with the one-shot schedule export. This example uses the Node build:

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

const data = {
  tasks: [
    { id: "phase", type: "summary" },
    { id: "design", parent: "phase", start_date: "2026-08-10 08:00", duration: 3 },
    { id: "build", parent: "phase", start_date: "2026-08-10 08:00", duration: 5 },
    { id: "release", type: "milestone", start_date: "2026-08-10 08:00" },
  ],
  links: [
    { id: "l1", source: "design", target: "build" },
    { id: "l2", source: "build", target: "release" },
  ],
};

const envelope = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (envelope.ok) {
  for (const u of envelope.result.updates) {
    console.log(u.id, u.kind, u.start_date ?? "", u.end_date ?? "");
  }
} else {
  const { kind, message, path } = envelope.error;
  console.error(`${kind} at ${path ?? "-"}: ${message}`);
}

The updates list in the result:

[
  { "end_date": "2026-08-20 08:00", "id": "build", "kind": "task", "start_date": "2026-08-13 08:00" },
  { "duration": 8, "end_date": "2026-08-20 08:00", "id": "phase", "kind": "summary" },
  { "end_date": "2026-08-20 08:00", "id": "release", "kind": "milestone", "start_date": "2026-08-20 08:00" }
]

The links push build after design and release after build. The summary phase has no dates of its own, so its span is rolled up from design and build. Its end moved, so it's reported too. Each entry carries only the fields that changed: phase still starts on Monday, so it has no start_date. design didn't move and isn't listed.

Each task type has its own date rule:

  • "task" (the default) is a leaf: two of start, end and duration.
  • "summary" takes both dates or neither. Its span is derived, so a summary with start_date + duration is rejected. A summary with no dates needs at least one dated child. "project" is accepted as an alias on input, and results say "summary".
  • "milestone" needs start_date or end_date. Its duration is 0.

parent links a task to its summary. A missing parent, null, "" and a value equal to config.root_id (default 0) all mean "no parent". A parent that doesn't exist, or a parent chain that loops back on itself, is a validation error.

The rest of TaskData covers constraints (constraint_type, constraint_date), calendars (calendar_id), and leveling inputs (priority, leveling_delay, pauses). See Links, constraints and summaries, Working time and calendars and Leveling resources.

Ids and dates#

An id is a number or a string, and the engine compares ids by their JavaScript key. A number and its string form are the same id, so a link can name task 1 as "1":

{
  "tasks": [
    { "id": 1, "start_date": "2026-08-10T09:30:45", "duration": 1 },
    { "id": "2", "start_date": "2026-08-10 09:30", "duration": 1 }
  ],
  "links": [{ "id": 1, "source": "1", "target": 2 }]
}
[{ "end_date": "2026-08-12 09:30", "id": "2", "kind": "task", "start_date": "2026-08-11 09:30" }]

Results echo each id in the form you sent it, so task "2" comes back as a string. The same rule causes collisions when you merge sources. Task 1 from one system and task "1" from another are duplicates, and strict validation rejects the second one at tasks[1].id. Numeric ids are JavaScript numbers, so integers past 2^53 lose precision.

A date string can be YYYY-MM-DD, YYYY-MM-DD HH:mm or YYYY-MM-DDTHH:mm, with optional seconds. Seconds are dropped, so 09:30:45 above is read as 09:30. Results always use YYYY-MM-DD HH:mm. An impossible date such as 2026-02-30 fails the whole call with a validation error whose path is null.

Dates carry no time zone. Convert zoned timestamps to the plan's local time before you build the JSON, and never pass Date objects: JSON.stringify turns them into UTC ISO strings. The engine never reads the clock, so an entirely undated project is rejected instead of being placed at "today".

Durations and units#

duration is working time in a unit: "minute", "hour" or "day". config.duration_unit sets the unit for the whole project (default "day"). A task's own duration_unit overrides it for that task.

{
  "config": { "duration_unit": "hour" },
  "tasks": [
    { "id": "review", "start_date": "2026-08-10 08:00", "duration": 12 },
    { "id": "sync", "start_date": "2026-08-10 08:00", "duration": 90, "duration_unit": "minute" }
  ]
}

With {"includeUnchanged": true} as the options, every task comes back with its dates:

[
  { "duration": 12, "end_date": "2026-08-11 12:00", "id": "review", "kind": "task", "start_date": "2026-08-10 08:00" },
  { "duration": 90, "end_date": "2026-08-10 09:30", "id": "sync", "kind": "task", "start_date": "2026-08-10 08:00" }
]

The default calendar works 08:00-12:00 and 13:00-17:00, Monday to Friday. Twelve working hours from Monday 08:00 fill Monday and end at Tuesday 12:00. See Load all task dates for more on includeUnchanged.

Durations can be fractional, so 1.5 days or 0.25 hours are valid. A negative duration is rejected. Week, month and year units aren't supported: "week" fails the call with a validation error.

Day units count whole working days. From a midnight anchor, Tuesday 00:00 + 4 days ends at Saturday 00:00, which is the close of Friday. From a non-midnight anchor, days are counted to the same time of day. With a sparse calendar this can skip working hours: on a Mon-Wed 9:00-13:00 calendar, a 3-day task from Monday 9:00 ends the next Monday 9:00. If that matters, use hour units.

Two task fields use elapsed time instead of working time: leveling_delay and pauses. Both are in the task's duration_unit, where a day is 24 hours, and both are rounded to whole minutes on use. The leveler writes them. Read Leveling resources before you set them by hand.

A LinkData record connects a source task to a target task. type is "fs" (finish-to-start, the default), "ss", "ff" or "sf". The numeric codes 0 to 3 also work, as numbers or strings. lag is working time added along the link, in config.duration_unit unless the link sets its own lag_unit.

{
  "tasks": [
    { "id": "a", "start_date": "2026-08-10 08:00", "duration": 2 },
    { "id": "b", "start_date": "2026-08-10 08:00", "duration": 2 },
    { "id": "c", "start_date": "2026-08-10 08:00", "duration": 2 }
  ],
  "links": [
    { "id": "ss", "source": "a", "target": "b", "type": "ss", "lag": 1 },
    { "id": "ff", "source": "b", "target": "c", "type": 2, "lag": 4, "lag_unit": "hour" },
    { "id": "stray", "source": "c", "target": "missing" }
  ]
}
{
  "notes": [{ "kind": "dropped-link", "link": "stray", "reason": "unknown target task \"missing\"" }],
  "updates": [
    { "end_date": "2026-08-13 08:00", "id": "b", "kind": "task", "start_date": "2026-08-11 08:00" },
    { "end_date": "2026-08-13 12:00", "id": "c", "kind": "task", "start_date": "2026-08-11 12:00" }
  ]
}

b starts one working day after a starts. c (type 2, finish-to-finish) finishes four working hours after b finishes.

Some links can't apply. The engine drops them and reports a dropped-link note with the reason instead of failing:

  • the source or target task doesn't exist;
  • the source or target task is unscheduled;
  • the link joins a summary to one of its own descendants, and config.descendant_links is false.

A duplicate link id is a validation error. A link from a task to itself is kept and reported in cycles as a one-task cycle. JSON can't carry NaN or Infinity: JSON.stringify writes them as null, and a null lag reads as 0. Check that numbers are finite before you serialize. How each link type moves its target, and how links attach to summaries, is in Links, constraints and summaries.

Fixed, unscheduled and completed tasks#

Three task states keep a task where it is:

  • "auto_scheduling": false makes the task fixed. The planner never moves it, but links from it still push its successors.
  • "unscheduled": true takes the task out of the plan. It isn't scheduled or reported, and it doesn't need dates. Its descendants go with it. Links that touch it are dropped with a note.
  • With config.use_progress on, a task with "progress": 1 is completed and stays put.
{
  "config": { "use_progress": true },
  "tasks": [
    { "id": "spec", "start_date": "2026-08-10 08:00", "duration": 1 },
    { "id": "done", "start_date": "2026-08-10 08:00", "duration": 2, "progress": 1 },
    { "id": "pinned", "start_date": "2026-08-10 08:00", "duration": 1, "auto_scheduling": false },
    { "id": "next", "start_date": "2026-08-10 08:00", "duration": 1 },
    { "id": "later", "unscheduled": true }
  ],
  "links": [
    { "id": 1, "source": "spec", "target": "done" },
    { "id": 2, "source": "spec", "target": "pinned" },
    { "id": 3, "source": "pinned", "target": "next" },
    { "id": 4, "source": "next", "target": "later" }
  ]
}
{
  "notes": [
    { "kind": "dropped-task", "reason": "unscheduled", "task": "later" },
    { "kind": "dropped-link", "link": 4, "reason": "target task \"later\" is unscheduled" }
  ],
  "updates": [
    { "end_date": "2026-08-12 08:00", "id": "next", "kind": "task", "start_date": "2026-08-11 08:00" }
  ]
}

spec would push done and pinned, but neither moves. pinned still pushes next. later and its link drop out with notes.

progress must be between 0 and 1. A completed task also stays in place inside a summary that moves as a block. In analytics, completed tasks have zero slack and are never critical.

Scheduling config#

config holds the project-wide switches. Send only the fields you want to change:

{
  "config": {
    "apply_constraints": true,
    "gap_behavior": "compress",
    "duration_unit": "hour",
    "project_start": "2026-08-10 08:00",
    "calendar_mode": "task",
    "root_id": -1
  },
  "tasks": [{ "id": 1, "parent": -1, "start_date": "2026-08-12 08:00", "duration": 4 }]
}
[{ "end_date": "2026-08-10 12:00", "id": 1, "kind": "task", "start_date": "2026-08-10 08:00" }]

"compress" pulls the task from Wednesday back to project_start. parent: -1 equals root_id, so task 1 has no parent.

The switches you'll change most often:

Field Default Effect
apply_constraints false Turns on the eight constraint types. Off, constraints are ignored, including mso pins
schedule_from_end false Backward (ALAP) planning. level refuses such projects
gap_behavior "preserve" "preserve" keeps tasks at their own dates when links allow; "compress" pulls every task to its earliest position
move_projects true Summaries move as a block; false schedules their leaves one by one
use_progress false Tasks with progress 1 are completed and fixed
project_start absent Lower bound for the plan, and the date source for undated tasks in repair mode
project_end absent Upper bound, used by backward projects
duration_unit "day" Unit of duration and lag
use_calendars true false makes every calendar flat 24/7 time
calendar_mode "resource" Which calendars place the work; "task" / "task-inherited" use task calendars only
max_iterations 8 Planner pass limit; must be at least 1
root_id 0 The parent value that means "no parent"

project_constraint, group_mode, descendant_links, strict_calendar and default_resource_capacity cover rarer cases. See SchedulingConfig for all of them.

root_id exists for data whose virtual root isn't 0. The engine strips it on import, so stored tasks never point at the root. With a non-zero root_id, a task with id 0 is an ordinary parent.

Left out (or null), use_calendars means Planning Engine schedules on calendars. Set it explicitly when your data should run on flat time.

Validation rules#

Strict validation, the default, rejects input that would need coercing or guessing. The call returns an error envelope instead of a result. A live JsonProject keeps its previous state and cached result.

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

const data = {
  tasks: [
    { id: "ok", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "broken", duration: 2 }, // only one of start / end / duration
  ],
};

const envelope = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!envelope.ok) {
  const { kind, message, path } = envelope.error;
  console.log(`${kind} at ${path ?? "-"}: ${message}`);
  // validation at tasks[1].start_date: task needs two of start_date / end_date / duration
}

The engine checks these rules after decoding and reports the field's path:

Input Error path
Duplicate task, link, resource or calendar id tasks[i].id, links[i].id, resources[i].id, calendars[i]
A leaf with fewer than two of start, end and duration tasks[i].start_date
Start, end and duration that don't agree tasks[i].duration
end_date before start_date tasks[i].end_date
A summary with only one date, or start + duration tasks[i].start_date
A summary with no dates and no dated children tasks[i]
A milestone with no date tasks[i].start_date
A negative duration tasks[i].duration
A dated constraint without constraint_date tasks[i].constraint_date
An unknown parent, or a cycle in the hierarchy tasks[i].parent
progress outside 0..1, negative leveling_delay tasks[i].progress, tasks[i].leveling_delay
Unsorted, overlapping or invalid pauses tasks[i].pauses[j]
An unknown task calendar_id tasks[i].calendar_id
A calendar with no working time calendars[i]
max_iterations: 0 config.max_iterations

Some values fail while the JSON is decoded, before any of those checks. They return a validation error with path: null: an invalid date, an unknown type, link type or enum string, a priority that isn't a whole number from 0 to 1000, a malformed hour range, "week", "month" or "year" units, and customWeeks calendars. A record that isn't a JSON object, a wrong JSON type, or a missing required field such as tasks or a task id is a parse error.

Note

A priority outside 0..1000 or a malformed hour range fails the whole call with a validation error that has no path. In apply, this means no operation from that change set is applied.

Resource and assignment rules (capacity, value, slices, calendars with no shared working time) are in Resources and assignments.

Some things aren't errors. Links that can't apply become dropped-link notes, unscheduled tasks become dropped-task notes, and dependency cycles are reported in cycles. All of them come back in an ok: true result.

When the data comes from a UI that saves messy records, switch to repair mode instead of fixing the input yourself. Repair is a JsonProject option:

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

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 1 },
    // Same id again: in repair mode the last record wins.
    { id: "a", start_date: "2026-08-10 08:00", duration: 3 },
  ],
};

const project = new JsonProject('{"validation":"repair"}');
try {
  const envelope = JSON.parse(project.solve(JSON.stringify(data), "{}"));
  if (envelope.ok) {
    for (const note of envelope.result.notes) {
      if (note.kind === "repaired") console.log(`${note.path}: ${note.message}`);
    }
    // tasks[1].id: duplicate id replaced the earlier record, retaining its position
  } else {
    console.error(envelope.error);
  }
} finally {
  project.free();
}

Repair deduplicates ids (the last record wins and keeps the first position), derives missing dates and durations, lets dates win in an inconsistent triple, and clears dated constraints without a date. Each fix shows up as a repaired note with its path. A task with no dates gets config.project_start, or the earliest date of another task. When neither exists, as in a fully undated project, repair fails at tasks[i].start_date. The JsonProject constructor throws the error envelope as a string if the options are invalid, for example an unknown validation mode. See Accept messy input and Error handling.