[Canonical page](<https://docs.dhtmlx.com/planning-engine/net/how-to/exchange-json-with-a-client/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/overview.md>) and [`Plen.Assign`](<https://docs.dhtmlx.com/planning-engine/net/api/assign/overview.md>), so you don't need to add them separately:

```bash
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`](<https://docs.dhtmlx.com/planning-engine/net/how-to/exchange-json-with-a-client.md#the-jsonproject-methods>) 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:

   ```csharp
   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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>).

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:

   ```csharp
   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:

   ```csharp
   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:

   ```csharp
   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](<https://docs.dhtmlx.com/planning-engine/net/how-to/share-a-project-between-threads.md>).

6. On the client, check `ok` in every response and read `result` or `error`, as described in [Read the envelope](<https://docs.dhtmlx.com/planning-engine/net/how-to/exchange-json-with-a-client.md#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`:

```csharp
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](<https://docs.dhtmlx.com/planning-engine/net/how-to/call-from-async-code.md>).

## 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:

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

Failure:

```json
{"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](<https://docs.dhtmlx.com/planning-engine/net/how-to/recover-from-rejected-changes.md>).

Each error kind maps to an exception in the native API: `"parse"` and `"validation"` for [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>), `"project"` for [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>). See [Error handling](<https://docs.dhtmlx.com/planning-engine/net/error-handling.md>).

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](<https://docs.dhtmlx.com/planning-engine/net/scheduling/reading-results.md>) and the [API reference](<https://docs.dhtmlx.com/planning-engine/net/api/overview.md>).

## 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:

```csharp
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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>), decode the JSON with `Decode` and wrap the result with `Wire.EnvelopeText`:

```csharp
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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>), [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/scheduling-changes.md>), 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/plen-exception.md>). 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:

```json
{"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.
