Exchange JSON with a client#
Run the scheduling backend for a browser Gantt: take project data and change sets as JSON, schedule them, and send the results back as JSON.
Prerequisites#
Add the Plen.Wire package. It references Plen.Core and Plen.Assign, so you don't need to add them separately:
Everything below lives in the Plen.Wire namespace. Task, link and config fields are snake_case (start_date, use_calendars). Call options and result fields are camelCase (includeUnchanged, baselineId). Dates go in as local strings ("2026-01-05", "2026-01-05 08:00", T separator and seconds allowed) and come back as "YYYY-MM-DD HH:MM".
Steps#
-
Create one
JsonProjectper open document. The constructor takes the project options as JSON. Pass"{}"for strict validation, or{"validation":"repair"}to fix messy data instead of rejecting it:var project = new JsonProject("""{"validation":"repair"}""");The constructor is the only member that throws. If the options don't decode, it throws
JsonProjectException. ItsErrorJsonholds the error envelope, and itsErrorholds theInputException. -
When the client loads a document, pass the project data JSON to
Solveand send the returned string back unchanged.includeUnchangedmakes the result list every task, not only the ones that moved:string envelope = project.Solve(dataJson, """{"includeUnchanged":true}"""); -
While the user drags or edits, pass the change set to
Preview. It schedules the change without keeping it:string envelope = project.Preview("""{"tasks":{"update":[{"id":"a","duration":3}]}}""", "{}"); -
When the user commits, pass the same change set to
Apply, then callSolvewithnulldata to reschedule the accepted state:string report = project.Apply(changesJson); string schedule = project.Solve(null, "{}"); -
Lock the project around every call. A
JsonProjectisn't thread-safe, and ASP.NET Core can run two requests for the same document at once. See Share a project between threads. -
On the client, check
okin every response and readresultorerror, as described in Read the envelope.
Full example#
A minimal ASP.NET Core API with three endpoints: load, preview and commit. Each one reads the request body as JSON text and returns the envelope as application/json:
using System.Collections.Concurrent;
using Plen.Wire;
var app = WebApplication.CreateBuilder(args).Build();
// One project per open document.
var projects = new ConcurrentDictionary<string, JsonProject>();
// Load: schedule the full data and return every task's dates.
app.MapPost("/projects/{id}/load", async (string id, HttpRequest request) =>
{
string data = await ReadBody(request);
var project = projects.GetOrAdd(id, _ => new JsonProject("""{"validation":"repair"}"""));
lock (project)
{
return Json(project.Solve(data, """{"includeUnchanged":true}"""));
}
});
// Preview: schedule a change set without keeping it.
app.MapPost("/projects/{id}/preview", async (string id, HttpRequest request) =>
{
string changes = await ReadBody(request);
if (!projects.TryGetValue(id, out var project))
return Results.NotFound();
lock (project)
{
return Json(project.Preview(changes, """{"reasons":true}"""));
}
});
// Commit: apply a change set, reschedule, return both envelopes.
app.MapPost("/projects/{id}/apply", async (string id, HttpRequest request) =>
{
string changes = await ReadBody(request);
if (!projects.TryGetValue(id, out var project))
return Results.NotFound();
lock (project)
{
string report = project.Apply(changes);
string schedule = project.Solve(null, "{}");
return Json($$"""{"apply":{{report}},"schedule":{{schedule}}}""");
}
});
app.Run();
static async Task<string> ReadBody(HttpRequest request)
{
using var reader = new StreamReader(request.Body);
return await reader.ReadToEndAsync();
}
static IResult Json(string envelope) => Results.Content(envelope, "application/json");
Every call is synchronous and CPU-bound. For large projects, or for Level, Evaluate and SuggestAssignments, move the work off the request thread. See Call from async code.
Read the envelope#
Every JsonProject method returns one of two shapes. Keys are sorted at every depth, and whole numbers are written as integers.
Success:
Failure:
Engine errors never escape as exceptions. Read error.kind to decide what to do:
kind |
Cause | path |
code |
|---|---|---|---|
"parse" |
JSON that doesn't decode: bad syntax, a wrong type, an unknown option or change-set key | null |
null |
"validation" |
Data the engine rejects, such as a task with fewer than two of start, end and duration | The field, e.g. "tasks[0].start_date". null when the decoder rejects a value |
null |
"project" |
A call the project's state doesn't allow | null |
"no-baseline", "stale-baseline" or "needs-resync" |
Handle the project codes on the client:
no-baseline: the project hasn't been solved yet. Send the full data to the load endpoint first.stale-baseline: thebaselineIdthe client sent in the options is out of date. Reload the document.needs-resync: anApplyrejected part of a change set. The apply envelope has"needsResync":trueand lists the rejections inrejected, and every call after it fails with this code. Send the full data toSolveagain. See Recover from rejected changes.
Each error kind maps to an exception in the native API: "parse" and "validation" for InputException, "project" for ProjectException. See Error handling.
Unknown fields in task, link, resource and assignment records are ignored, so you can send records with their extra UI properties (text, progress, custom fields). Call options and the change set's own keys are strict: an unknown key there fails with "parse".
The JsonProject methods#
Each method takes JSON text and returns the envelope string:
| Method | Input | Result |
|---|---|---|
Solve(dataJson, optionsJson) |
Project data, or null to re-solve the accepted data |
Schedule result |
Preview(changesJson, optionsJson) |
Change set | Schedule result, not accepted |
Analyze(dataJson, optionsJson) |
Project data, or null |
Schedule result with analytics, not accepted |
Apply(changesJson) |
Change set | Apply report |
Level(optionsJson) |
Leveling options | Leveling result, accepted |
LoadProfile(optionsJson) |
Load-profile query | Load profile |
Evaluate(candidatesJson, optionsJson) |
Candidate list | Evaluate result |
Slots(poolJson, requirementsJson) |
Worker pool and requirement list | Slot list |
SuggestAssignments(slotsJson, optionsJson) |
Slot list | Suggest result |
Clone() returns an independent JsonProject. Use it to preview a leveling run or a multi-step edit before you accept it. The fields of each input and result are the wire names of the native types. See Reading results and the API reference.
Schedule once, without a project#
For a stateless endpoint, call Wire.Schedule. It decodes the data and options, schedules them in a fresh project and returns the envelope:
using Plen.Wire;
string envelope = Wire.Schedule(dataJson, "{}");
Wire.Schedule always uses strict validation.
Decode JSON into typed records#
To keep working with the native Project, decode the JSON with Decode and wrap the result with Wire.EnvelopeText:
using System.Text.Json.Nodes;
using Plen;
using Plen.Wire;
ProjectData data = Decode.FromJson<ProjectData>(dataJson);
SolveOptions options = Decode.FromJson<SolveOptions>("""{"includeUnchanged":true}""");
var project = new Project();
string envelope = Wire.EnvelopeText(() => project.Solve(data, options));
// A change set that is already parsed, e.g. part of a larger request body.
JsonNode? body = JsonNode.Parse(requestJson);
SchedulingChanges changes = Decode.Changes(body?["changes"]);
string report = Wire.EnvelopeText(() => project.Apply(changes));
Decode.FromJson<T>(text)decodesProjectData,SchedulingChanges, every options type, and the records nested in them. A type with no decoder throwsNotSupportedException.Decode.Project,Decode.Changes,Decode.SolveOptionsand the other named decoders take aJsonElementor aJsonNodeyou've already parsed.- A decode failure throws
InputExceptionwithKindParse(orValidationfor a value the decoder rejects) and anullPath. Wire.EnvelopeText(run)runs the call and returns{"ok":true,"result":…}, or the error envelope ifrunthrows aPlenException. Any other exception propagates. Decode insiderunto get decode errors as envelopes too.Wire.ErrorText(exception)builds the error envelope for an exception you caught yourself.
Wire.Envelope and Wire.Error return the same envelopes as a JsonNode. JsonNode.ToJsonString() with default options escapes some characters (non-ASCII, `, <). The wire text writes them verbatim. When the exact text matters, send the EnvelopeText / ErrorText string.
Result#
The load endpoint answers with every task's dates:
That's the response for two tasks a (2 days) and b (1 day) linked finish-to-start, both starting on 2026-01-05 08:00. Each updates entry uses the task-data field names, so the client can write it straight back to its gantt task. The baselineId differs on every run.