Azure Functions Core Tools today ships every supported language stack — .NET, Node, Python, Java, PowerShell, TypeScript — inside a single, monolithic CLI binary. As a developer using the CLI:
- I download (and update) a large binary that contains support for languages I will never use.
- Adding support for a new stack requires a full Core Tools release, even when nothing in the core CLI actually changed.
- I cannot pin the version of language-specific tooling (templates, project initialization rules, packaging behavior) independently from the version of the CLI itself.
- As a workload owner (a team that maintains support for one stack), I cannot ship fixes or new templates on my own cadence — I am gated on a Core Tools release and on the Core Tools team's review bandwidth.
- As a Core Tools maintainer, every cross-stack change is a coupling risk: a refactor in the .NET path can regress the Python path because everything links into the same assembly.
The core problem: language-stack support is fused into the CLI at compile time, so the CLI cannot grow, shrink, or evolve along stack boundaries.
The CLI gains a workload engine: a runtime extensibility model in which each language stack (and any other optional capability) ships as a standalone NuGet package — a "workload" — that the CLI installs into a per-user store and loads dynamically at startup.
From the user's perspective:
- The base CLI is small and language-agnostic. It ships only the commands every user needs (
init,new,pack,start,version,workload). - The user installs only the workloads they actually need:
func workload install Azure.Functions.Cli.Workload.Node. - Installed workloads contribute to the existing commands they care about (e.g. the Node workload teaches
func inithow to initialize a Node project) and may also contribute brand-new top-level subcommands. - Workloads can be installed, uninstalled, updated, and listed without reinstalling the CLI.
- The user can see at a glance which workloads are installed and which workload owns which contribution, so failures and conflicts are diagnosable.
From the workload-author perspective:
- A workload is a class library that targets
net10.0, references onlyAzure.Functions.Cli.Abstractions, implementsIWorkload, and registers its services through a singleConfigure(FunctionsCliBuilder)seam — the same shape asIWebJobsStartup. - A workload may register an
IProjectInitializer(to plug intofunc init), a top-levelCommand(to add a brand-new subcommand), or any supporting service its contributions need — all via plain .NET DI. - A workload ships and versions independently of the CLI on its own NuGet feed and release cadence.
- As a Functions developer, I want to install only the language workloads I use, so that I don't download tooling for stacks I don't care about.
- As a Functions developer, I want to install a workload by NuGet package id (e.g.
func workload install Azure.Functions.Cli.Workload.Node), so that I can use the same identifier I'd find on nuget.org. - As a Functions developer, I want to install a workload at a specific version (e.g.
--version 1.2.3), so that I can reproduce a teammate's environment exactly. - As a Functions developer, I want
func workload uninstall <packageId>to remove a workload cleanly, so that I can roll back without leaving stale files behind. - As a Functions developer, I want
func workload listto show every installed workload with its version, so that I can audit my environment. - As a Functions developer, I want a clear error message if I try to install a workload that doesn't exist on any configured feed, so that I don't have to guess what went wrong.
- As a Functions developer, I want a clear error message if I try to install a package that exists but is not a valid workload, so that I'm not silently left with a broken install.
- As a Functions developer, I want install/uninstall to be transactional — either fully succeed or leave my environment unchanged — so that a failed install never half-corrupts my CLI.
- As a Functions developer, I want all workload state (installed assemblies, manifest, cache) stored under a single per-user directory I can inspect or wipe, so that I have a single mental model of "where workloads live."
- As a Functions developer running in a containerized or CI environment, I want to redirect that per-user directory via an environment variable (
FUNC_CLI_HOME), so that I can isolate workload state per build.
- As a Functions developer, I want
func initto discover and use the project-initialization logic of every installed workload, so thatfunc init --stack nodeworks once I've installed the Node workload. - As a Functions developer, I want
func initto display "no workloads installed" rather than crashing or silently doing nothing when I haven't installed any, so that the failure mode is obvious and recoverable. - As a Functions developer, I want a workload to be able to add brand-new top-level subcommands (e.g.
func node-diagnostics), so that workload authors can ship capabilities that don't fit into the existing commands. - As a Functions developer, I want help output to reflect the commands and options of installed workloads, so that
func --helpis accurate for my environment. - As a Functions developer, I want a startup-time error that names the offending workload when two workloads contribute conflicting commands, so that I can tell which workload to upgrade or uninstall.
- As a Functions developer, I want a clean error message when an installed workload is broken (missing assembly, wrong type, mismatched ABI), so that I can diagnose and uninstall without reading a stack trace.
- As a workload author, I want a stable
Azure.Functions.Cli.AbstractionsNuGet package, so that my workload survives Core Tools updates without recompilation. - As a workload author, I want a single entry point (
IWorkload.Configure(FunctionsCliBuilder)), so that there is one obvious place to register everything my workload contributes. - As a workload author, I want to register an
IProjectInitializerto extendfunc init, so that I can teach the CLI about my language stack without modifying the CLI. - As a workload author, I want to register a top-level
Commandto ship a brand-new subcommand, so that I can deliver capabilities the existing commands don't cover. - As a workload author, I want to register supporting services into DI that my contributions consume, so that I can structure my workload using the same patterns used in the rest of the CLI.
- As a workload author, I want my workload to load into its own
AssemblyLoadContext, so that my third-party dependencies don't conflict with another workload's dependencies or with the host's. - As a workload author, I want types I share with the host (
IWorkload,Command, BCL types) to resolve to the host's instances rather than my own, so that DI registrations and command-tree composition work across the ALC boundary. - As a workload author, I want to ship and version my workload independently of Core Tools releases, so that I can fix a template bug without waiting for a CLI release train.
- As a workload author, I want a documented, tested example workload to copy from, so that I can scaffold a new workload in minutes.
- As a workload author, I want the CLI to validate my workload at install time (the assembly loads, the declared type exists, the type implements
IWorkload), so that broken builds fail fast at install rather than at every CLI invocation.
- As a Core Tools maintainer, I want stack-specific code to live in workload repositories (or at least workload projects), so that I can refactor the core CLI without coordinating with five language teams.
- As a Core Tools maintainer, I want every contribution to record which workload owns it, so that I can produce diagnostics and conflict messages that name the offending workload.
- As a Core Tools maintainer, I want a stable manifest format (
workloads.json) describing what is installed, so that install/uninstall/list/load all read and write the same source of truth. - As a Core Tools maintainer, I want the workload loader to be pure — manifest in, instances out — so that I can unit-test it with a real fixture workload without mocking DI.
- Three-layer model:
Func.Cli(the executable) loads workloads;Func.Cli.Abstractions(a NuGet package) defines the contract; each workload (Func.Cli.Workload.*) is a standalone NuGet package referenced by no project at compile time. - DI is the seam: workloads contribute via
IWorkload.Configure(FunctionsCliBuilder). Built-in commands consume contributions through standard .NET DI (e.g.InitCommandconsumesIEnumerable<IProjectInitializer>). - Per-workload
AssemblyLoadContext: each loaded workload runs in its own ALC for dependency isolation. The ALC delegates host-shared assemblies (Abstractions, BCL) back to the default context to preserve type identity across the boundary. - No "capabilities" abstraction: explicitly rejected. Contributions are plain DI registrations (interfaces and
Commandinstances), not a parallel capability-flag system.
- Workload Storage — pure data layer: paths layout (
~/.azure-functions/, override viaFUNC_CLI_HOME), manifest POCOs (GlobalManifest,GlobalManifestEntry,EntryPointSpec), atomic read/write ofworkloads.json. - Workload Loader — pure function: manifest in, list of loaded workload instances out. Owns the per-workload ALC, delegates host-shared assemblies, validates each entry (assembly exists, type exists, type implements
IWorkload), throws a graceful error per failure mode. - Workload Installer — side-effecting: acquires a NuGet package (resolves version, downloads, extracts to the install directory), parses the package's nuspec for entry-point metadata, writes a manifest entry transactionally, fails-rolls-back on any error.
- Workload Uninstaller — side-effecting: removes a manifest entry and the corresponding install directory, idempotent.
- Workload Command Surface — the
func workload install / uninstall / listcommand tree, wired into the existingWorkloadCommandshell. - Workload Command Ownership — typed contribution wrapper (
WorkloadCommandContribution { IWorkload Owner; Command Command }) so that every workload-contributed command carries its owner;Parserconsumes this wrapper and names the offending workload in conflict diagnostics. - Workload Registration (existing) — the bridge that runs each loaded workload's
Configure(FunctionsCliBuilder)against the host builder during startup.
- Workload Storage is a deep module: simple interface (read/write a manifest), all the path/atomicity/serialization complexity hidden behind it.
- Workload Loader is a deep module: trivial signature (
LoadInstalledAsync(GlobalManifest, CT)), all the ALC machinery and validation hidden behind it. - Workload Installer is a deep module: simple verb (
InstallAsync(packageId, version?, CT)), all the NuGet resolution / extraction / nuspec parsing / transactional rollback hidden behind it.
- One entry per installed workload. Each entry records: package id, version, install directory, and an
EntryPointSpec(assembly file name + fully-qualified type name). - The
EntryPointSpecis sourced from the workload's nuspec at install time; the loader treats the manifest as the single source of truth at load time. - The manifest is written atomically (write-temp + rename) so a crashed install never produces a partial JSON file.
- All user-facing failures throw
GracefulException(existing convention), whichProgram.cscatches and prints without a stack trace. - Loader validation failures (missing assembly, missing type, type does not implement
IWorkload) name the workload (package id) in the message. - Install failures (package not found, ABI mismatch, IO error) leave the user's manifest unchanged.
- Root:
~/.azure-functions/(configurable viaFUNC_CLI_HOME). - Manifest:
<root>/workloads.json. - Install directory per workload:
<root>/workloads/<packageId>/<version>/.
- Worker runtime aliases (e.g.
-s js→ Node workload) are carried as data onWorkloadInfoand resolved during command invocation; no special-case logic in the loader. - Telemetry — workload load failures and install/uninstall outcomes are recorded through the existing OpenTelemetry pipeline.
A good test exercises a workload-engine module through its public interface and asserts on observable behavior (the manifest written, the instances returned, the error message thrown). It does not assert on internal data structures, the order of file IO, or which collaborators were called. Tests should survive a refactor of how a module accomplishes its job; they should fail when the module's contract with its callers changes.
- Workload Storage — unit tests covering: roundtrip serialization of
GlobalManifest,FUNC_CLI_HOMEoverride, atomic write semantics, missing-file read returning an empty manifest. (Already in PR-A, 8 tests.) - Workload Loader — integration-style tests that load a real fixture workload assembly: empty manifest produces empty list, single entry hydrates an
IWorkloadinstance, missing assembly / missing type / wrong type each throw aGracefulExceptionnaming the workload, multiple workloads load into isolated ALCs without type-identity collisions. (Already in PR-B, 6 tests.) - Workload Installer — tests covering: install resolves and writes a manifest entry, install at a specific version, install of a non-existent package fails cleanly, install of a non-workload package fails validation, install rolls back on partial failure.
- Workload Uninstaller — tests covering: uninstall removes the manifest entry and install directory, uninstall of an unknown workload is a no-op (or graceful error — TBD), uninstall is idempotent.
- Workload Command Ownership — tests covering: a
Commandregistered throughWorkloadCommandContributioncarries its owner;Parserproduces a diagnostic that names both owners when two workloads register a conflicting command.
- The Loader's fixture-assembly pattern (a separate test-fixture project referenced as
ContentwithReferenceOutputAssembly=false, copied into the test bin directory) is the canonical pattern for any future test that needs a real workload to load. FakeDotnetCliRunneris the canonical pattern for tests that need to stub out an external CLI invocation; the Installer's NuGet acquisition can use the same pattern for the NuGet client surface.GracefulException-based error tests (assert message + exit-code-equivalent) are used throughoutFunc.Cli.Tests.
- Migrating the existing in-tree language support (.NET, Node, Python, Java, PowerShell, TypeScript) into standalone workload packages. The engine is the foundation; the migration is a separate, much larger effort tracked elsewhere.
- A workload marketplace / discovery UI. Workloads are installed by NuGet package id from configured feeds; there is no
func workload search. - Sandboxing / permissions. Workloads run in-process with full trust; the ALC provides isolation for dependency resolution, not for security.
- Side-by-side installs of multiple versions of the same workload. One version per package id at a time.
- Cross-workload IPC or service discovery beyond plain DI. If two workloads need to talk, they do it through a service contract published in
Abstractions(and the protocol/governance for adding toAbstractionsis itself out of scope here). - Hot-reload of workloads inside a running CLI invocation. Workloads are loaded once at startup.
- Telemetry schema changes for workload-attributed metrics. Existing metrics gain workload context where natural; no new metric pipeline.
- This PRD describes the design that has been implemented incrementally across the PR stack already in flight. The merged PRs (#4881, #4882, #4883) deliver the abstractions and DI host. PR #4893 (storage) and PR #4894 (loader) are open as draft. The installer, the workload command surface, and the command-ownership wrapper are tracked as the next stack of PRs (PR-C and PR-D).
- The shape of
IWorkload.Configure(FunctionsCliBuilder)deliberately mirrorsIWebJobsStartup.Configure(IWebJobsBuilder)so that workload authors familiar with the WebJobs/Functions runtime feel at home. - The decision to make the Loader pure (manifest in, instances out) means DI registration is the caller's responsibility — this keeps the Loader a deep module and lets
Program.csown the wiring. - The
workloads.jsonformat is internal-but-stable: external tooling may read it but should not write it. Install/uninstall is the supported mutation path.