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:

dotnet add package Plen.Wire

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#

  1. Create one JsonProject per 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. Its ErrorJson holds the error envelope, and its Error holds the InputException.

  2. When the client loads a document, pass the project data JSON to Solve and send the returned string back unchanged. includeUnchanged makes the result list every task, not only the ones that moved:

    string envelope = project.Solve(dataJson, """{"includeUnchanged":true}""");
    
  3. 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}]}}""", "{}");
    
  4. When the user commits, pass the same change set to Apply, then call Solve with null data to reschedule the accepted state:

    string report = project.Apply(changesJson);
    string schedule = project.Solve(null, "{}");
    
  5. Lock the project around every call. A JsonProject isn't thread-safe, and ASP.NET Core can run two requests for the same document at once. See Share a project between threads.

  6. On the client, check ok in every response and read result or error, 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:

{"ok":true,"result":{"baselineId":"1-e8149a4e1dfc94ca","conflicts":[],"converged":true,"cycles":[],"notes":[],"updates":[...]}}

Failure:

{"error":{"code":null,"kind":"validation","message":"task needs two of start_date / end_date / duration","path":"tasks[0].start_date"},"ok":false}

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: the baselineId the client sent in the options is out of date. Reload the document.
  • needs-resync: an Apply rejected part of a change set. The apply envelope has "needsResync":true and lists the rejections in rejected, and every call after it fails with this code. Send the full data to Solve again. 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) decodes ProjectData, SchedulingChanges, every options type, and the records nested in them. A type with no decoder throws NotSupportedException.
  • Decode.Project, Decode.Changes, Decode.SolveOptions and the other named decoders take a JsonElement or a JsonNode you've already parsed.
  • A decode failure throws InputException with Kind Parse (or Validation for a value the decoder rejects) and a null Path.
  • Wire.EnvelopeText(run) runs the call and returns {"ok":true,"result":…}, or the error envelope if run throws a PlenException. Any other exception propagates. Decode inside run to 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:

{"ok":true,"result":{"baselineId":"1-e8149a4e1dfc94ca","conflicts":[],"converged":true,"cycles":[],"notes":[],"updates":[{"duration":2,"end_date":"2026-01-07 08:00","id":"a","kind":"task","start_date":"2026-01-05 08:00"},{"duration":1,"end_date":"2026-01-08 08:00","id":"b","kind":"task","start_date":"2026-01-07 08:00"}]}}

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.