.NET Workers

Desktop only

Run compatible .NET business logic alongside a Power Platform ToolBox tool by packaging it as a .NET console tool. Your tool's TypeScript UI communicates with the worker through the declared worker API; the worker is not loaded into the webview and does not replace the UI.

Overview

This is useful when you want to reuse .NET business logic, for example logic shared with an XrmToolBox plugin. Keep the user interface in your tool and move only the work that benefits from .NET into a console worker.

The normal flow is:

  1. Package a .NET console application as a NuGet .NET tool.
  2. Declare its exact package identity and supported platforms in pptb.config.json.
  3. Explicitly connect to it from the tool, wait for the initialization handshake, and send named requests.
  4. For host operations such as Dataverse queries, let the TypeScript tool call the ToolBox API and return only the required data to the worker.

PPTB manages package preparation, native-code consent, process launch, JSON-RPC transport, cancellation, and cleanup. Tool authors should use the public session API rather than implementing process or stdio management themselves.

Prepare the Worker

Create a .NET console application that can run as a NuGet .NET tool. The package must declare a command, and its published package must contain the worker entry point and all required managed dependencies. PPTB installs and launches the console tool; it does not load an arbitrary DLL.

For example, create a console project and configure it as a .NET tool package:

dotnet new console --name Contoso.SqlWorker --framework net8.0

Set the project file to include the tool packaging properties:

File: Contoso.SqlWorker/Contoso.SqlWorker.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PackAsTool>true</PackAsTool>
    <PackageId>Contoso.SqlWorker</PackageId>
    <Version>1.4.2</Version>
    <ToolCommandName>contoso-sql-worker</ToolCommandName>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="StreamJsonRpc" Version="2.25.29" />
  </ItemGroup>
</Project>

Implement the worker's JSON-RPC startup and named methods in the console application's entry point, then pack it:

dotnet pack ./Contoso.SqlWorker/Contoso.SqlWorker.csproj --configuration Release --output ./local-nuget-feed

The resulting .nupkg must include the worker entry point and its dependencies. Keep PackageId, Version, ToolCommandName, and TargetFramework consistent with the declaration in the next section. End users need only a compatible .NET runtime installed. No .NET SDK is required to run a worker. You still need an SDK that supports your target framework to build and pack it.

Package requirements

PPTB downloads the exact .nupkg, extracts only its .nuspec and tools/** folder, checks it against your declaration, and launches the entry DLL with dotnet exec. NuGet dependencies are not resolved at install time; the worker runs only the files inside tools/<targetFramework>/any/ of its package. The cache is reused on later starts and rebuilt automatically if it's damaged. Check that:

  • The project is packed with PackAsTool=true. The .nuspec must contain <packageType name="DotnetTool" />, which dotnet pack adds automatically.
  • The tool is a framework-dependent, portable tool: tools/<targetFramework>/any/.
    • Don't set RuntimeIdentifier, RuntimeIdentifiers, ToolPackageRuntimeIdentifiers, SelfContained, or PublishAot.
    • RID-specific tool packages are rejected, including those using the .NET 10 DotnetToolSettings.xml Version 2 format. Native-executable tools (Runner="executable") are also rejected.
  • The package contains a tools/<declared targetFramework>/any/ folder. A multi-targeted package works as long as the declared target framework's folder is present.
  • That folder contains:
    • DotnetToolSettings.xml, with exactly one command whose Name equals ToolCommandName and the declared command
    • the entry .dll
    • <Entry>.runtimeconfig.json
    • <Entry>.deps.json
    • every dependency assembly
  • Every runtime asset listed in deps.json exists in that folder, and the entry DLL is listed there.
  • dotnet pack copies PackageReference and ProjectReference assemblies into the folder. Still open the .nupkg (it's a ZIP) and check. PPTB won't download missing dependencies.
  • The package doesn't ship a *.runtimeconfig.dev.json.
  • Use the sample worker .csproj as the reference configuration.

For a complete working example, see the sample worker project, including its project file.

Declare the Worker

Declare the worker in the tool package's pptb.config.json. This declaration identifies an exact package; it is not an executable path, shell command, permission grant, or place to put local feed settings.

File: pptb.config.json

{
  "workers": {
    "engine": {
      "kind": "dotnet-tool",
      "packageId": "Contoso.SqlWorker",
      "packageVersion": "1.4.2",
      "command": "contoso-sql-worker",
      "dotnet": {
        "targetFramework": "net8.0",
        "minimumRuntimeVersion": "8.0.0",
        "rollForward": "Major"
      },
      "platforms": ["all"]
    }
  }
}
  • packageId and packageVersion identify an exact NuGet package version. Do not use floating versions, ranges, or tags.
    • The exact version must be published and indexed on nuget.org. Private and authenticated feeds aren't supported.
    • A newly published version can take several minutes after upload before it can be downloaded. Until then PPTB reports PACKAGE_NOT_FOUND.
    • Matching is case-insensitive, and versions are normalized (1.2 = 1.2.0).
  • command is the command declared by the NuGet tool package, not a file path or an arbitrary argument list.
  • dotnet.targetFramework is the worker executable's target framework. A shared library can use another compatible target framework.
  • dotnet.minimumRuntimeVersion is a stable major.minor.patch version matching the target framework's major and minor versions, and must not be lower than the worker's requirement. It must exactly equal the framework.version in the packed <Entry>.runtimeconfig.json. That's usually X.Y.0 (for example 8.0.0 for net8.0, 10.0.0 for net10.0) unless the project sets RuntimeFrameworkVersion. A mismatch fails with PACKAGE_INVALID.
  • dotnet.rollForward accepts Disable, Latest, Minor, or Major; the default is Major. If the project sets <RollForward>, it must match the declaration. Latest in pptb.config.json corresponds to LatestMajor in the project. Leaving it out of the project is fine.
  • platforms is required. Supported aliases are all, windows-x64, windows-arm64, macos-x64, macos-arm64, linux-x64, and linux-arm64. Use all by itself. It refers to PPTB's versioned support matrix, not every platform .NET may support.
  • Declaring workers requires features.minAPI in your tool's package.json.

See Tool Configuration for the full field reference.

Connect from TypeScript

Starting a worker is explicit. Loading the npm tool does not download its package or launch a process. Use toolboxAPI.workers.connect() with the declared worker ID, register any reverse-call handlers, then await ready before sending work:

File: sample-ppt-tool/src/dotnetWorker.ts

const worker = toolboxAPI.workers.connect('engine', {
  requests: {
    'dataverse/getAccounts': async ({ top }) => {
      const fetchXml = buildAccountFetchXml(top)
      const result = await dataverseAPI.fetchXmlQuery(fetchXml)

      return result.value.map((account) => ({
        name: account.name,
        address1_country: account.address1_country,
      }))
    },
  },
})

await worker.ready

const summary = await worker.request('accounts/summarizeByCountry', {
  top: 10,
})

await worker.stop()

The callback method name must match the method requested by the worker. Keep the callback's input and output narrow, validate caller-provided values, and return plain serializable data. The worker API also supports notifications and cancellation. If stop() fails, keep the session available so the user can retry cleanup; do not treat the worker as stopped or start a replacement prematurely.

Implement the Worker Protocol

The worker communicates over framed UTF-8 JSON-RPC on stdin and stdout using the jsonrpc-stdio-v1 protocol. Before handling tool requests, it must respond to platform/initialize with the same protocol name and version 1. Keep stdout exclusively for protocol messages; write diagnostics to stderr.

Expose named methods for your operations. A worker can call a registered TypeScript callback while a request is in progress. Its JSON-RPC loop must continue reading and dispatching messages during that callback, or the request can deadlock. Cancellation is cooperative: long-running operations should observe their cancellation token.

The entry point creates the stdio transport, registers the request methods, and keeps the JSON-RPC listener running:

File: Contoso.SqlWorker/Program.cs

using StreamJsonRpc;

using var handler = new HeaderDelimitedMessageHandler(
  Console.OpenStandardOutput(),
  Console.OpenStandardInput());
using var rpc = new JsonRpc(handler);
rpc.AddLocalRpcTarget(new WorkerMethods(rpc));
rpc.StartListening();
Console.Error.WriteLine("Worker ready; stdout is reserved for JSON-RPC.");
await rpc.Completion;

The handler can expose a narrow method that requests data from the TypeScript callback and then performs the .NET work. For example, this method summarizes accounts by country:

File: Contoso.SqlWorker/WorkerMethods.cs

using Newtonsoft.Json;
using StreamJsonRpc;

public sealed class WorkerMethods(JsonRpc rpc)
{
  [JsonRpcMethod("platform/initialize")]
  public object Initialize(string protocol, int protocolVersion)
  {
    if (protocol != "jsonrpc-stdio-v1" || protocolVersion != 1)
      throw new InvalidOperationException("Unsupported PPTB worker protocol");

    return new { protocol, protocolVersion };
  }

  [JsonRpcMethod("accounts/summarizeByCountry")]
  public async Task<object> SummarizeByCountryAsync(
    int top,
    CancellationToken cancellationToken)
  {
    if (top is < 1 or > 100)
      throw new ArgumentOutOfRangeException(nameof(top));

    var response = await rpc.InvokeWithParameterObjectAsync<AccountReply>(
      "dataverse/getAccounts",
      new { top },
      cancellationToken);

    var countries = response.Value
      .GroupBy(
        account => string.IsNullOrWhiteSpace(account.Address1Country)
          ? "Unspecified"
          : account.Address1Country.Trim(),
        StringComparer.OrdinalIgnoreCase)
      .OrderByDescending(group => group.Count())
      .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
      .Select(group => new { country = group.Key, count = group.Count() });

    return new { totalAccounts = response.Value.Length, countries };
  }
}

public sealed record AccountReply(
  [property: JsonProperty("value")] AccountRow[] Value);

public sealed record AccountRow(
  [property: JsonProperty("name")] string? Name,
  [property: JsonProperty("address1_country")] string? Address1Country);

The callback name dataverse/getAccounts must match the TypeScript requests handler. The complete implementations are in Program.cs and WorkerMethods.cs.

For a Dataverse operation, a good boundary is for the worker to request the specific data it needs, for TypeScript to build and execute the query with dataverseAPI, and for TypeScript to return only the selected fields. Do not pass access tokens, connection credentials, or arbitrary SDK object graphs to the worker.

Runtime and Platform Support

PPTB needs only an installed .NET runtime (Microsoft.NETCore.App) that satisfies the declared minimumRuntimeVersion and rollForward. No SDK is required. PPTB does not install a missing runtime.

Because users must install the runtime themselves, state the required .NET runtime version in your tool's description or README.

rollForward controls runtime selection:

ValueSelection behavior
DisableUse the exact minimum runtime version, including patch.
LatestUse the highest available compatible major, minor, and patch.
MinorPrefer the requested line and latest patch; otherwise use the next minor in the same major.
MajorPrefer the requested line and latest patch, then a later minor in that major, then a higher major if needed. This is the default.

Do not assume a later .NET major is compatible just because it can load the target framework. Test the worker against the runtimes and operating systems you declare, including native dependencies. A successful package build on one machine is not cross-platform qualification.

Security and Lifecycle

A worker is native code running as the current user, not an operating-system sandbox. It may access that user's files and network or start child processes. Consent and stdio do not restrict those capabilities.

Keep startup explicit and explain why the worker is needed. Do not send credentials or bearer tokens to it. Tool close, crashes, updates, consent revocation, and application shutdown must leave the process cleanly stopped; handle stop failures as failures, not as successful cleanup.

Test and Debug

There are two ways to test a worker during tool development:

1. Test an unpublished worker from a cloned Desktop App repository

Clone the desktop-app repository and use an eligible unpackaged developer build. From your tool project directory, pack Contoso.SqlWorker into a flat feed directory in the Desktop App checkout. For example, if the tool project and desktop-app clone are sibling directories:

dotnet pack ./Contoso.SqlWorker/Contoso.SqlWorker.csproj --configuration Release --output ../desktop-app/local-nuget-feed

Inspect the .nupkg to ensure it contains the worker entry point and all required assemblies; a project reference alone does not prove the package is complete. Then run the app from the desktop-app repository root, setting the absolute feed path in the same shell that launches the app:

PowerShell

$env:PPTB_DOTNET_LOCAL_NUGET_FEED = (Resolve-Path .\local-nuget-feed).Path
pnpm run dev

macOS Terminal

export PPTB_DOTNET_LOCAL_NUGET_FEED="$(cd ./local-nuget-feed && pwd)"
pnpm run dev

Linux Terminal

export PPTB_DOTNET_LOCAL_NUGET_FEED="$(cd ./local-nuget-feed && pwd)"
pnpm run dev

The local feed path is main-process environment configuration: do not put it in pptb.config.json or expose it to the tool UI. The local-feed option is restricted to eligible unpackaged developer builds; packaged and production-mode builds reject it. See the HTML sample tool for a complete package and protocol example.

  • The feed must be a flat folder of regular files: no subfolders and no symlinks.
  • PPTB reads only the exact <packageid>.<version>.nupkg file named by the declaration.
  • Because the package already contains its dependencies, the local feed never needs to serve other packages.
  • Repacking with the same version is supported. PPTB detects the new bytes, asks for native-code consent again, and caches it in a separate folder. You don't need to bump the version on every iteration.

2. Test a worker published to NuGet

Publish the worker package to NuGet, update packageVersion in pptb.config.json to that exact published version, and pack/build your tool as usual. Then follow Debugging in the Desktop App: start your tool's dev-watch process, enable the Debug menu, and use Load Local Tool to load the tool project root. The Desktop App will prepare the declared worker from NuGet; no local-feed environment variable or Desktop App source clone is needed.

In either workflow, explicitly start the worker from the tool and exercise initialization, a normal request, reverse callbacks, cancellation, and stop/close behavior. If package contents or worker startup fail, check the app's developer console/logs as well as the tool DevTools.

Troubleshooting worker preparation

If preparation fails, the tool receives only an error code; the detailed reason appears in the Desktop App logs.

CodeMeaningTypical fix
RUNTIME_NOT_FOUNDNo installed runtime satisfies minimumRuntimeVersion / rollForwardInstall the required .NET runtime in the standard location
HOST_NOT_FOUNDNo dotnet found in the standard install locationsInstall .NET with the official installer, not a user-local install
PACKAGE_NOT_FOUNDThe exact ID/version isn't on nuget.org (or isn't in the local feed)Publish, wait for indexing, check packageId/packageVersion
DOWNLOAD_FAILEDNetwork, proxy, timeout or HTTP errorCheck connectivity or the app's proxy settings, then retry
PACKAGE_INVALIDPackage layout or metadata doesn't match the declaration (the log says exactly what)See "Package requirements" under Prepare the Worker
PACKAGE_HASH_MISMATCHLocal-feed package bytes changed after approvalRestart the worker and approve again
WORKSPACE_INVALIDThe cache directory couldn't be writtenCheck disk space and permissions on the app data folder
  • The prepared package is cached in <app data>/powerplatform-toolbox/native-workers/<package-id>/<version>/. It's safe to delete this folder to force a fresh download.
  • Older app versions left folders such as <hash>.stage-… and <hash>-<rid>.lock in native-workers. These are removed automatically and can be ignored.

Was this page helpful?