Wire format#
This guide covers the conventions every raw Planning Engine call shares: how you pass JSON in, how you read the envelope that comes back, and how ids, dates, numbers, null and unknown fields are read. Reference pages for each export link here instead of repeating these rules.
Strings in, strings out#
The raw module has two exports, the JsonProject class and the schedule function. Every argument is JSON text and every return value is JSON text. Nothing native crosses the boundary: no objects, no Dates, no exceptions for bad input.
Each call follows the same four steps:
- Serialize the request and options with
JSON.stringify. - Call the export.
- Parse the returned string with
JSON.parse. - Check
ok, then readresultorerror.
The plen CLI speaks the same wire format, byte for byte.
Here's the full loop in a browser or ES module host, with a stateful project:
await ;
const = ;
const = new;
In Node, the CommonJS build loads synchronously, so there's no init(). The stateless schedule export needs no handle to free:
const = ;
const = ;
const = ;
;
Every export is synchronous. Only module instantiation is async in the browser build. How to load each build, and how to create, clone and free handles, is in Module and handles.
Envelope#
Every export returns one of two shapes.
Success wraps the payload in result:
That's the response to the browser example above: task b moved after a, and a didn't move, so only b is listed. The result payload depends on the export. Each reference page names its payload type.
Failure carries an error object with four fields, always all present:
| Field | Type | Meaning |
|---|---|---|
kind |
"parse" | "validation" | "project" |
The error class. Branch on this. |
code |
string | null |
Set only for project errors. |
message |
string | Human-readable text. Don't parse it. |
path |
string | null |
The input field at fault, such as tasks[3].constraint_date, when the engine knows it. |
baselineId values are opaque tokens. Don't compare them across runs: the same data can get a new token on a repeat run.
Error table#
Errors come at two levels. Most failures arrive in the envelope. A short list of failures throws in the host instead.
Errors in the envelope#
kind |
code |
path |
When |
|---|---|---|---|
parse |
null |
null |
The text isn't valid JSON, a value has the wrong JSON type, a required field is missing or null, an unknown field appears where unknown fields are rejected, or a record is an array instead of an object. |
validation |
null |
null |
The decoder rejects a value: an invalid date, an unknown enum string, a bad link type, a non-integer or out-of-range integer, days without exactly 7 entries, customWeeks, level: true. |
validation |
null |
field path | The engine rejects the data or options as a whole: duplicate ids, inconsistent dates, a hierarchy cycle, an unknown id in a scope list, and similar checks. path points at the field, e.g. tasks[0].start_date or slots[2].allowed[0]. |
project |
"no-baseline" |
null |
The call needs solved dates, and the project has never been solved or was edited by apply since the last solve. |
project |
"stale-baseline" |
null |
A baselineId you passed doesn't name the current baseline. |
project |
"needs-resync" |
null |
An earlier apply rejected an operation, and no solve with full data has followed. |
For project errors, message holds the same string as code.
An input error before solving leaves the project as it was: the previous accepted state and cached result are kept.
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.
Some outcomes look like failures but come back with ok: true, as data in the result: scheduling conflicts, dependency cycles, failure to converge, rejected apply operations, per-candidate evaluate failures, and unfilled or invalid assignment slots. Check the result fields for them.
Host-level failures#
| Failure | What the host sees |
|---|---|
Bad options in new JsonProject(options_json) |
The constructor throws a string: the error envelope text. It's not an Error object. |
| A method called on a freed handle | A thrown error from the generated bindings. |
The .wasm can't be fetched or instantiated |
init() rejects. Calling exports before init() resolves fails too. |
| An internal engine failure | A wasm trap, surfaced as WebAssembly.RuntimeError. |
The constructor is the only export that throws for bad input. Parse what it throws:
await ;
let ;
Recovery for each error kind is in Error handling.
Field spelling#
Two naming styles meet on the wire, and which one applies depends on what the object is:
| Object | Style | Examples |
|---|---|---|
Project data: config, calendars, tasks, links, resources, assignments |
snake_case | start_date, duration_unit, calendar_id, apply_constraints |
Change-set records (add and update entries) |
snake_case | leveling_delay, constraint_type |
Result update entries (updates[]) |
snake_case | start_date, end_date, leveling_delay |
| Call options | camelCase | includeUnchanged, baselineId, clearFirst |
SchedulingChanges.baselineId |
camelCase | baselineId |
| Envelope and every other result field | camelCase | criticalPath, needsResync, removedTasks |
Task and link types are both spelled type.
Update entries reuse the task-data names on purpose. A result entry decodes as a valid task update, so you can send it back in tasks.update as is. Its extra kind and reason fields are ignored there.
Getting the style wrong fails in different ways:
- In call options, a wrongly spelled field is an unknown field, so the call fails with
parse.{"clear_first": true}in evaluate options is rejected. - In project data and change-set records, unknown fields are ignored. A task with
"startDate"decodes as a task with no start date, and the error (if any) comes later from validation.
Ids, dates and numbers#
Ids#
An id is a JSON number or a string. Ids are compared like JavaScript object keys, so 1 and "1" name the same task. Numeric ids are double-precision floats, like JavaScript numbers; send ids beyond Number.MAX_SAFE_INTEGER as strings. Whole-number ids come back as JSON integers.
A task parent of "", or equal to config.root_id (default 0), means the task has no parent.
Dates#
Dates are strings: a naive local date-time at minute precision. There's no timezone, no daylight saving and no "today". Convert to local wall-clock time before you send dates in.
| Direction | Accepted forms |
|---|---|
| Input | YYYY-MM-DD, YYYY-MM-DD HH:mm, YYYY-MM-DDTHH:mm, each with optional :ss. Seconds are dropped. |
| Output | Always YYYY-MM-DD HH:mm. |
So "2026-01-01T08:00:59" comes back as "2026-01-01 08:00". An unparsable date fails with validation, message invalid date, path: null.
Calendar exception keys in worktime.dates are YYYY-MM-DD only. Hour ranges in worktime.hours are "H:MM-H:MM" strings, "24:00" is the only accepted hour-24 value, and a range that ends before it starts crosses midnight.
The generated type files type dates as string | Date. Date objects are a convenience of the JS glue only; on the raw surface you send strings.
Numbers#
Numbers are plain JSON numbers, and fractions are allowed where the field allows them. Whole-valued results come back as integers: 5, not 5.0.
Some fields take integers only:
priority- integer 0..1000.max_iterations, requirementcount, suggestpasses- non-negative integers.8.0is accepted;8.5fails withvalidation.
JSON can't carry NaN or Infinity. JSON.stringify turns them into null, and null then reads as "use the default" or "clear the field" (see the next section). Check that numbers are finite before you serialize.
Enum values#
Enum fields are strings. A few accept extra input forms:
| Field | Accepted | Notes |
|---|---|---|
Link type |
"fs", "ss", "ff", "sf" in any case; 0-3; "0"-"3" |
0 = FS, 1 = SS, 2 = FF, 3 = SF |
Task type |
"task", "summary", "project", "milestone" |
"project" is an input alias of "summary". Output says "summary". |
duration_unit, lag_unit |
"minute", "hour", "day" |
week, month, year are rejected. |
Any other string fails with validation and no path. The full lists of enum values are in Common types.
Omitted, null and unknown fields#
Input: omitted and null#
In project data and in options, an omitted field and a null field mean the same thing: use the default. {"includeUnchanged": null} is the same as {}. A null config gives the default config.
Update records (update entries of a change set) are the exception. There, omitted and null differ:
| In an update entry | Effect |
|---|---|
| Field omitted | Leave the field unchanged. |
Field set to null |
Clear an optional field, or restore a scalar default. |
| Field set to a value | Set it. |
This update clears the start date and the constraint, sets progress, and leaves every other field of task 1 alone. For constraint_type, an empty string also clears. pauses: null clears the list, and a new pauses list replaces the whole list. The full list of update fields is on TaskUpdate.
Input: unknown fields#
Whether an unknown field is ignored or rejected depends on the object:
| Ignored | Rejected with parse |
|---|---|
| Project data records: tasks, links, resources, assignments, calendars | Every call options object |
config |
SchedulingChanges itself, and its add / update / remove buckets |
Change-set add and update entries |
Candidate |
| Slots, pool, workers, skills and requirements |
Records ignore unknown fields so you can pass UI data with its own extras (text, open, custom columns) and feed result entries back as updates. Options and change-set containers are strict, so a typo like {"tasks": {"rename": []}} or {"maxRounds": 5} fails loudly instead of being dropped.
Output: absent fields#
Optional result fields are omitted when they don't apply, not set to null. baselineId is missing when there's no baseline. An update entry lists only the fields that changed. analytics appears only when you asked for it.
A few result fields use null as a real value, and their reference pages say so: for example fromResource: null in a suggest step means the slot was filled, not moved.
Required fields#
A required field must be present and non-null. A missing or null required field fails the call with parse and no path. For example, {"tasks": null} as project data is a parse error.
The main required fields:
| Object | Required |
|---|---|
ProjectData |
tasks |
| Task, resource and calendar records | id |
| Link in project data | id, source, target |
Link in links.add |
source, target (id is generated when omitted) |
| Assignment | task, resource, value |
| Update entries | id |
Subtree |
task |
Profile query, loadProfile options |
from, to |
Candidate |
id, changes |
| Slot | id, task, allowed |
| Requirement | id, task, matches |
tasks is required in ProjectData; send "tasks": [] for an empty project.
Assignments also accept task_id and resource_id as aliases of task and resource. When both spellings are present, the primary name wins. In an assignment update, setting task or resource to null is rejected: a required endpoint can't be cleared.
Records must be JSON objects. A positional array such as [1, "2026-01-05", 3] in place of a task is a parse error.
Required fields only cover the shape. The engine checks the content next: for example, a leaf task needs at least two of start_date, end_date and duration, and an entirely undated project is rejected. Those checks fail with validation and a path. The rules are in Project data.
For every request and response type, field by field, see the types overview.