Quick start#
By the end of this page, you'll have a C# program that schedules two linked tasks, and a live project that reschedules them when one changes.
Requirements#
- .NET 10. The package targets
net10.0and has nullable reference type annotations. - No async setup. Every call is synchronous and CPU-only, with no I/O and no clock.
Install#
The packages come from a private NuGet feed at git.dhtmlx.com. With your license or trial you received a username and a password for one of two editions:
| Edition | Feed URL | Packages |
|---|---|---|
| Full | https://git.dhtmlx.com/api/packages/plen/nuget/index.json |
Plen.Core, Plen.Assign, Plen.Wire |
| Trial | https://git.dhtmlx.com/api/packages/plen-trial/nuget/index.json |
Plen.Core.Trial, Plen.Assign.Trial, Plen.Wire.Trial |
The trial is the full engine with one limit: a project with more than 50 tasks is rejected with an InputException. Only the package ids differ. The assemblies, namespaces and types are the same, so the code in these docs runs on both.
Each account sees only its own edition. Trial credentials see only the .Trial packages on the plen-trial feed.
Configure access to the feed#
The examples use the full feed. For the trial, replace plen with plen-trial in the URL.
Option A: nuget.config in your solution (recommended). Create a nuget.config next to your solution file (.sln or .slnx), or next to the project if you have no solution:
<!-- Optional: take Plen.* only from the DHTMLX feed. -->
The username and password come from environment variables, so the file can go into source control. Set them in your shell, and as secrets on your CI server:
# bash / zsh
# PowerShell
$env:DHTMLX_NUGET_USER = "your-username"
$env:DHTMLX_NUGET_PASSWORD = "your-password"
Option B: register the feed once per machine. This writes the source to your user-level NuGet config instead of the solution:
--store-password-in-clear-text is required on macOS and Linux, where NuGet can't encrypt passwords. On Windows, leave it out and the password is stored encrypted.
Visual Studio and Rider read the same nuget.config. Without one, add the feed in the IDE. In Visual Studio, open Tools → Options → NuGet Package Manager → Package Sources, add the feed URL, and enter the username and password when asked. In Rider, open Settings → Build, Execution, Deployment → NuGet → Sources and enter the feed URL, username and password.
Add the package#
Create a console app and add the package:
Everything on this page lives in the Plen namespace, so one using Plen; is enough. Two other packages exist, and you don't need either one here. Plen.Assign adds skill matching for auto-assignment. Plen.Wire reads and writes the JSON format, and pulls in Plen.Core and Plen.Assign. With the trial, add the .Trial id wherever these docs name a package.
Move from the trial to the full edition#
-
Change the feed URL from
.../plen-trial/...to.../plen/...and use your full-edition credentials. -
Swap the package references and keep the version:
Do the same for
Plen.Wire.TrialandPlen.Assign.Trialif you use them. The code doesn't change.
If the restore fails#
401 Unauthorized: the username or password is wrong, or the variables aren't set in the shell that runsdotnet. With two-factor authentication, use an access token as the password.Unable to find package Plen.Core: the feed and the package don't match. Trial credentials see only the.Trialpackages. Also check that the URL ends in/nuget/index.json.- Changed credentials aren't picked up: run
dotnet nuget locals http-cache --clear.
Schedule a snapshot#
Start with the smallest useful plan: two tasks and a finish-to-start link. Both tasks start on Monday, 2026-08-10, at 08:00, so task 2 starts too early. It has to wait for task 1 to finish.
Replace Program.cs with this:
using Plen;
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0)
?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData(1) { StartDate = monday, Duration = 2 },
new TaskData(2) { StartDate = monday, Duration = 1 },
],
Links =
[
new LinkData { Id = 1, Source = 1, Target = 2, LinkType = LinkType.Fs },
],
};
try
{
var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
Console.WriteLine($"task {update.Id}: {update.StartDate} -> {update.EndDate}");
}
}
catch (InputException e)
{
Console.Error.WriteLine($"invalid input at {e.Path}: {e.Message}");
return 1;
}
return 0;
Run it with dotnet run:
task 2: 2026-08-12 08:00 -> 2026-08-13 08:00
Engine.Schedule takes a ProjectData snapshot and returns a ScheduleResult. Its Updates list holds only the tasks that moved, as Mutation records. Task 1 didn't move, so it's not in the list.
Durations are working time in days. The default calendar works Monday to Friday, 08:00-12:00 and 13:00-17:00. That's why the two-day task 1 ends on Wednesday at 08:00, not on Tuesday evening.
Dates are Moment values: local time, minute precision, no time zone. Moment.FromYmdHm returns Moment? and gives null for an invalid date, so the example throws instead of reading .Value blindly. Ids are Id values. An int, a double or a string converts to Id on its own, and 1 and "1" name the same task. Records, ids and dates covers these types in detail.
When the input is invalid#
Every leaf task needs two of StartDate, EndDate and Duration. Remove task 2's start date and keep only its duration:
new TaskData(2) { Duration = 1 },
Engine.Schedule now rejects the snapshot before it schedules anything, and the catch block prints:
invalid input at tasks[1].start_date: task needs two of start_date / end_date / duration
The InputException has a Path that points to the field that failed. The path uses the JSON field names, so StartDate shows up as start_date. Add the start date back and the program runs again.
Bad input is the only thing that makes this call throw. Dependency cycles, constraint conflicts and dropped links come back as data in ScheduleResult (Cycles, Conflicts, Notes), and the call still returns normally.
Keep a live project#
Engine.Schedule fits batch jobs and request handlers that see each snapshot once. An editor backend works differently. It loads the plan once, then sends small changes as the user works. That's what Project does.
This program loads the same two tasks, previews a change, then commits it:
using Plen;
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0)
?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData(1) { StartDate = monday, Duration = 2 },
new TaskData(2) { StartDate = monday, Duration = 1 },
],
Links =
[
new LinkData { Id = 1, Source = 1, Target = 2, LinkType = LinkType.Fs },
],
};
var project = new Project();
var first = project.Solve(data, new SolveOptions());
Console.WriteLine($"loaded, {first.Updates.Count} task(s) moved");
// The user stretches task 1 to three days.
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(1) { Duration = 3 });
var preview = project.Preview(changes, new PreviewOptions());
Console.WriteLine($"preview: {preview.Updates.Count} task(s) would move");
var report = project.Apply(changes);
if (report.NeedsResync)
{
Console.Error.WriteLine("an edit was rejected; reload the project with Solve(data, ...)");
return 1;
}
var result = project.Solve(null, new SolveOptions());
foreach (var update in result.Updates)
{
Console.WriteLine($"task {update.Id}: {update.StartDate} -> {update.EndDate}");
}
return 0;
Output:
loaded, 1 task(s) moved
preview: 2 task(s) would move
task 1: 2026-08-10 08:00 -> 2026-08-13 08:00
task 2: 2026-08-13 08:00 -> 2026-08-14 08:00
Each call does one job:
Project.Solvewithdataloads and schedules the snapshot. It works on its own copy ofdata, so later changes to your object don't reach the project. The scheduled dates become the project's baseline.Project.Previewruns the change on a copy of the baseline. The project doesn't change, so it's safe to call on every mouse move during a drag.Project.Applycommits the change and marks the project as unsolved.Solve(null, ...)reschedules the current state. Task 1 is reported because it was edited, and task 2 moves to follow it.
Edits are SchedulingChanges. A TaskUpdate sets only the fields you give it. Each field is a Patch<T>: a plain value such as Duration = 3 sets it, Patch<double>.Null clears it, and Patch<T>.Missing (the default) leaves it alone.
A Project isn't IDisposable, so there's nothing to release when you're done with it. It also isn't thread-safe: use one instance from one thread at a time.
When an edit is rejected#
Apply accepts what it can and reports the rest in the returned ApplyReport. It doesn't throw for a bad operation. To see this, remove a task that doesn't exist. Add this line before the Apply call:
changes.Tasks.Remove.Add(99);
The report's Rejected list now has an entry with this Path and Message:
tasks.remove[0] unknown task id
NeedsResync is now true. Your copy of the plan and the project's copy no longer match. The next Solve(null, ...) throws a ProjectException with Code == ProjectError.NeedsResync (message needs-resync). To fix it, send the full data again with Solve(data, ...). That's why the example checks NeedsResync before it solves. Recover from rejected changes covers this flow in detail.
Next steps#
- Records, ids and dates: building records,
Id,Moment,Patch<T>and read-only results. - Project data: tasks, links, calendars, resources and assignments.
- Scheduling a project: one-shot and stateful scheduling, options and subtrees.
- Reading results: updates, conflicts, cycles and notes.
- Working-time calendars: hours, weekdays and date exceptions.
- Editing a project:
Apply, baselines and resync. - Previewing changes: what-if results without committing.
- Leveling resources: remove resource overallocation.
- Error handling:
InputException,ProjectExceptionand what comes back as data instead. - Call from async code: run long calls off the request thread.
- Share a project between threads: clones and locking.
- API reference: every public type and method.