How to run PowerShell Core scripts from .NET Core applications

I wrote an MSDN blog post a few years back (here) that demonstrated how to run PowerShell scripts by hosting the PowerShell runtime inside a C# / .NET application. A few things have changed since that post; including the introduction of .NET Core and PowerShell Core, better async support, and a few new best practices.

In this article we will jump forward to take a look at runspace execution for PowerShell Core and .NET Core applications. Including topics like project setup, runspace usage examples, stream handling, shared app domain use cases, and some helpful troubleshooting tips.

Full sample code

Complete example code for everything in this post can be found on Github at this repository: https://github.com/keithbabinec/PowerShellHostedRunspaceStarterkits

Project setup

Start by creating a new .NET Core console application. Then add a package reference to the PowerShell SDK. At the time of this writing, I’m using .NET Core 3.1 with version 6.2.4 of the PowerShell SDK. This is the foundation step for all three of the example scenarios below.

Install-Package Microsoft.PowerShell.SDK

Example 1 (Default runspace)

For the first example we are going to show the bare minimum code to run a script with a couple of input parameters, under the default runspace, and printing the resulting pipeline objects.

Sidebar: What is a runspace?

A PowerShell runspace executes your script code and maintains context/state for the session (loaded modules, imported functions, remote session state, etc). You can view the current runspaces in any PowerShell session by running the Get-Runspace cmdlet. In this example we use the default runspace.

Calling PowerShell.Create() will get you a new hosted instance of PowerShell you can use within your .NET process. We call the .AddScript() and .AddParameters() methods to pass our input, and then call .InvokeAsync() to execute the pipeline.

After the pipeline finishes we print any objects that were sent to the pipeline/output stream.

/// <summary>
/// Runs a PowerShell script with parameters and prints the resulting pipeline objects to the console output. 
/// </summary>
/// <param name="scriptContents">The script file contents.</param>
/// <param name="scriptParameters">A dictionary of parameter names and parameter values.</param>
public async Task RunScript(string scriptContents, Dictionary<string, object> scriptParameters)
{
  // create a new hosted PowerShell instance using the default runspace.
  // wrap in a using statement to ensure resources are cleaned up.
  
  using (PowerShell ps = PowerShell.Create())
  {
    // specify the script code to run.
    ps.AddScript(scriptContents);
    
    // specify the parameters to pass into the script.
    ps.AddParameters(scriptParameters);
    
    // execute the script and await the result.
    var pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
    
    // print the resulting pipeline objects to the console.
    foreach (var item in pipelineObjects)
    {
      Console.WriteLine(item.BaseObject.ToString());
    }
  }
}

Example 2 (Custom runspace pool)

The previous example works fine in many simple hosted script scenarios. However if you need to handle additional stream output (warnings, non-terminating errors, etc), or if you need to execute many scripts simultaneously, this example will demonstrate those usage patterns.

Sidebar: What are PowerShell streams?

PowerShell uses Piping (|) to pass objects from one cmdlet to another. In order to differentiate between pipeline objects and other output (errors, warnings, traces, etc), PowerShell writes objects to the different streams (or pipes) for these different types of data. That way the next cmdlet in the chain can just watch the Output stream and ignore the other streams.

In this example we initialize and use a RunspacePool. Maintaining a runspace pool is helpful for multithreading scenarios where you need to run many scripts at the same time and have control over the throttle settings. It also allows us to import modules for each runspace used, configure thread re-use options, or specify remote connection settings.

We also watch for three of the other output streams (warning, error, and information). This is helpful if we need to monitor things other than pipeline output, like Write-Host/Write-Information.

using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Threading.Tasks;

namespace CustomRunspaceStarter
{
  /// <summary>
  /// Contains functionality for executing PowerShell scripts.
  /// </summary>
  public class HostedRunspace
  {
    /// <summary>
    /// The PowerShell runspace pool.
    /// </summary>
    private RunspacePool RsPool { get; set; }
    
    /// <summary>
    /// Initialize the runspace pool.
    /// </summary>
    /// <param name="minRunspaces"></param>
    /// <param name="maxRunspaces"></param>
    public void InitializeRunspaces(int minRunspaces, int maxRunspaces, string[] modulesToLoad)
    {
      // create the default session state.
      // session state can be used to set things like execution policy, language constraints, etc.
      // optionally load any modules (by name) that were supplied.
      
      var defaultSessionState = InitialSessionState.CreateDefault();
      defaultSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted;
      
      foreach (var moduleName in modulesToLoad)
      {
        defaultSessionState.ImportPSModule(moduleName);
      }
      
      // use the runspace factory to create a pool of runspaces
      // with a minimum and maximum number of runspaces to maintain.
      
      RsPool = RunspaceFactory.CreateRunspacePool(defaultSessionState);
      RsPool.SetMinRunspaces(minRunspaces);
      RsPool.SetMaxRunspaces(maxRunspaces);
      
      // set the pool options for thread use.
      // we can throw away or re-use the threads depending on the usage scenario.
      
      RsPool.ThreadOptions = PSThreadOptions.UseNewThread;
      
      // open the pool. 
      // this will start by initializing the minimum number of runspaces.
      
      RsPool.Open();
    }
    
    /// <summary>
    /// Runs a PowerShell script with parameters and prints the resulting pipeline objects to the console output. 
    /// </summary>
    /// <param name="scriptContents">The script file contents.</param>
    /// <param name="scriptParameters">A dictionary of parameter names and parameter values.</param>
    public async Task RunScript(string scriptContents, Dictionary<string, object> scriptParameters)
    {
      if (RsPool == null)
      {
        throw new ApplicationException("Runspace Pool must be initialized before calling RunScript().");
      }
      
      // create a new hosted PowerShell instance using a custom runspace.
      // wrap in a using statement to ensure resources are cleaned up.
      
      using (PowerShell ps = PowerShell.Create())
      {
        // use the runspace pool.
        ps.RunspacePool = RsPool;
        
        // specify the script code to run.
        ps.AddScript(scriptContents);
        
        // specify the parameters to pass into the script.
        ps.AddParameters(scriptParameters);
        
        // subscribe to events from some of the streams
        ps.Streams.Error.DataAdded += Error_DataAdded;
        ps.Streams.Warning.DataAdded += Warning_DataAdded;
        ps.Streams.Information.DataAdded += Information_DataAdded;
        
        // execute the script and await the result.
        var pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
        
        // print the resulting pipeline objects to the console.
        Console.WriteLine("----- Pipeline Output below this point -----");
        foreach (var item in pipelineObjects)
        {
          Console.WriteLine(item.BaseObject.ToString());
        }
      }
    }
    
    /// <summary>
    /// Handles data-added events for the information stream.
    /// </summary>
    /// <remarks>
    /// Note: Write-Host and Write-Information messages will end up in the information stream.
    /// </remarks>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Information_DataAdded(object sender, DataAddedEventArgs e)
    {
      var streamObjectsReceived = sender as PSDataCollection<InformationRecord>;
      var currentStreamRecord = streamObjectsReceived[e.Index];
      
      Console.WriteLine($"InfoStreamEvent: {currentStreamRecord.MessageData}");
    }
    
    /// <summary>
    /// Handles data-added events for the warning stream.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Warning_DataAdded(object sender, DataAddedEventArgs e)
    {
      var streamObjectsReceived = sender as PSDataCollection<WarningRecord>;
      var currentStreamRecord = streamObjectsReceived[e.Index];
      
      Console.WriteLine($"WarningStreamEvent: {currentStreamRecord.Message}");
    }
    
    /// <summary>
    /// Handles data-added events for the error stream.
    /// </summary>
    /// <remarks>
    /// Note: Uncaught terminating errors will stop the pipeline completely.
    /// Non-terminating errors will be written to this stream and execution will continue.
    /// </remarks>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Error_DataAdded(object sender, DataAddedEventArgs e)
    {
      var streamObjectsReceived = sender as PSDataCollection<ErrorRecord>;
      var currentStreamRecord = streamObjectsReceived[e.Index];
      
      Console.WriteLine($"ErrorStreamEvent: {currentStreamRecord.Exception}");
    }
  }
}

Shared App Domain

One final example I created explores the shared .NET Application Domain. Since a hosted PowerShell runspace lives in the same .NET app domain as your calling application, you can do the following:

  1. Send real (not serialized) class object instances defined in your hosting application as parameters into a script.
  2. Instantiate class object instances in the script (for types defined in your hosting application).
  3. Call static methods on types defined in your hosting application.
  4. Return class objects to the pipeline.

The sample code for this one is pretty lengthy, so head over to the Github repo to view the full example.

Note: There is no special configuration or setup required to enable a shared app domain. The shared app domain can be leveraged in all runspace use cases. The main purpose of this code sample is to demonstrate how to leverage it.

You can leverage the code in this example to do things like:

  1. Pass rich objects between different scripts in a multi-step pipeline.
  2. Provide pre-configured complex class instances in as parameters that may be easier to configure in .NET than in PowerShell.
  3. Return a results/summary class object to the pipeline, instead of a large array of pipeline objects you need to interpret to determine if the script was successful or not.

Common problems

Here are a few of the common pitfalls that you might encounter when running hosted PowerShell scripts:

Execution policy

Executing scripts can fail if your execution policy is configured to prevent scripts from running. You may need to change your execution policy to resolve this. Read more here.

Pipeline errors

Terminating errors are the fatal errors that can occur inside your script code. If this happens the pipeline stops immediately and .InvokeAsync() will throw an exception. Implement error handling in your scripts to resolve this. Read more here.

Pipeline output types

If your hosting application prints the pipeline output objects to the console with .ToString() like the examples above do, then you may see type names printed in some cases. For example: a script calling the Get-Service cmdlet would show this in your console output repeated several times: “System.ServiceProcess.ServiceController“.

This is expected behavior because the pipeline returns real class objects. The Get-Service command returns this type. For .NET types that do not implement a custom ToString() override, the type name will be printed instead. To solve this you can reach into the object and pull out or print other properties you need (like <serviceobject>.ServiceName).

Module loading problems

Trying to load a module inside your script and it isn’t loading? Check the basics:

  1. Is the module actually installed on the system?
  2. Is the module supported in PowerShell Core? Or is it a legacy PowerShell module?
  3. Is it the correct bitness? PowerShell comes in x86 and x64 architectures, and some modules only work in one architecture.

41 thoughts on “How to run PowerShell Core scripts from .NET Core applications

  1. Jorge Silva May 15, 2020 / 4:00 am

    Hi! Great article, it helped me somewhat… I say somewhat because what I intend to do is to execute a lot of scripts over Azure. But, here’s the catch, the first command is Login-AzAccount, and when I use your code, the app stalls. On the powershell command line, a warning is shown when I execute that command, telling me to open the browser, etc, etc, etc. But on your sample code, nothing is output in any of the streams, and the app just stalls. Can you help me?

    Jorge from Portugal.

    Liked by 1 person

  2. Saurabh June 6, 2020 / 11:34 am

    Hi
    I am trying to run some azureAD commands in function app 3.0 in C#
    and I am getting an exception “ClassName”: “System.Management.Automation.CommandNotFoundException”,
    “Message”: “The ‘Get-AzureRmTenant’ command was found in the module ‘AzureRM.Profile’, but the module could not be loaded. For more information, run ‘Import-Module AzureRM.Profile’.”,
    “Data”: null,
    and when I try to load the module using this cmdlet Import-Module AzureRM.Profile’
    it gives an error saying incorrect cmdlet

    To cross-check whether the commands are available or not I ran Get-commands that listed whole lot of AzureRM commands

    Like

    • keithbabinec June 6, 2020 / 12:01 pm

      So I haven’t run into this particular problem before, but I’m guessing that it might be caused by trying to load AzureRM cmdlets from dotnet core / PowerShell Core. I would recommend trying to use the newer dotnet core supported Azure commands available in the ‘Az’ module. The equivalent/replacement command is Get-AzTenant. This also assumes that you have the Az module installed.

      Like

      • Saurabh June 6, 2020 / 9:57 pm

        There is a restriction in function apps. It does not allow to add/install modules from nuget or powershell gallery. Either i download and manual upload and then try to import module
        not sure it will work thats too hacky.

        Function apps pwershell execution using C# has too many limitations.
        My basic doubt is if module is not available why the command is listed ?

        Anyway i am going to open issue on github let see if get answers.

        Thanks Man

        Like

      • keithbabinec July 15, 2020 / 9:34 pm

        Not quite sure what’s going on there, sorry– I haven’t tried to get this working within a linux hosted function app before.

        Like

  3. jonskeet July 14, 2020 / 7:15 am

    I may be missing something – I’m trying to understand the Shared AppDomain example, and I don’t see any difference between that and the default runspace example. Is there code missing here, or does the default runspace use the same AppDomain as the caller anyway?

    Liked by 1 person

    • keithbabinec July 14, 2020 / 9:19 am

      Hi Jon– yeah great question. The shared appdomain example does in fact use the same code sample as the default runspace. Meaning you don’t have to do anything special to the runspace setup/configuration to enable this, it just works out of the box. The main difference in the project files here is in the program.cs file has different script code, and the the output handling after the runspace is called. I will add some clarifying comments in the Github repo and the post to make this more clear.

      Like

  4. farag September 8, 2020 / 3:37 am

    Thank you for this great article,but i run the code and it is run successful without any errors but it is not effecting at all. my script is creating new local user and when i test it directly from the power-shell it is works and the user created but when i try it from this code it is not creating any user.

    Liked by 1 person

    • keithbabinec September 8, 2020 / 2:13 pm

      Its likely that there is an error in your script when it is being executed in the runspace. To help troubleshoot I would recommend setting: $ErrorActionPreference = ‘Stop’, at the top of your script, that way any terminating errors bubble up. Its possible there are errors you are not seeing.

      Like

  5. HeidiP September 23, 2020 / 1:15 am

    Great article, thanks a lot.
    I have a question about the bitness. Apparently our VS environment opens the x86 PowerShell architecture, and then a call to MSOnline fails because it only works in x64. Setting the target plattform of the project to x64 does not seem to help. Do you have any suggestions on how to force PowerShell to run in the x64 architecture?
    Thanks!

    Like

    • keithbabinec September 23, 2020 / 1:25 pm

      If you set the platform target in the project properties to x64 (instead of anycpu) that should normally help. Is it just happening while launching in Visual Studio debug mode? Or does it also run incorrectly when launched outside of Visual Studio? If it only behaves incorrectly in Visual Studio, perhaps it could be the Debug launch settings in VS (for example its specifically launching an x86 debug process)

      Like

      • HeidiP September 24, 2020 / 12:20 am

        Thanks a lot for your reply! The error is also happening when running the Release build of the executable outside Visual Studio. Thanks!

        Like

    • keithbabinec September 25, 2020 / 2:13 pm

      Hmm not really sure what’s happening then, I would recommend trying a new stackoverflow question where you can post the code and more details.

      Like

  6. Siva November 5, 2020 / 2:13 pm

    This is a really good article. Thanks Keith for putting this together.
    Is there a way I can run PowerShell 2 commands using this? I have some legacy scripts that I need to run via a .netcore app. And they require running using PowerShell 2.

    Liked by 1 person

    • keithbabinec November 5, 2020 / 10:30 pm

      @Siva — great question. The answer depends on the cmdlets used in your scripts.

      Using the runspace/SDK method described in this article means you can only run PowerShell Core compatible commands. Some of the commands used in your existing PS 2.0 scripts might be compatible, some might not be. I would review the breaking changes lists for PS 6.0 to help identify those potential issues: https://docs.microsoft.com/en-us/powershell/scripting/whats-new/breaking-changes-ps6?view=powershell-7

      If you have commands that would break under PowerShell Core, then the only other options are to update your PowerShell scripts to be compatible, or to use the Process.Start() in .NET to launch a shell process to call Windows PowerShell. You just won’t get the integration used in the examples here.

      Like

      • Siva November 8, 2020 / 12:36 pm

        Thank you Keith for your reply. We are to run some of the PS scripts in here – https://github.com/redcanaryco/atomic-red-team/tree/master/atomics

        And we do not plan to refactor these scripts at this stage. One of the script that I was running as using `Get-WmiObject` which definitely seem to have been v1 cmdlet. So that indicates that I only have the option of using Process.Start()! I might give that a go.

        Liked by 1 person

  7. Patrick January 29, 2021 / 7:58 am

    Thank you for the article. I am getting an error though, which is it seems to not be finding any of the cmdlets I have in my script.

    “ErrorStreamEvent: System.Management.Automation.CommandNotFoundException: The term ‘Connect-AzAccount’ is not recognized as the name of a cmdlet, function, script file, or operable program.”

    I have posted a question on stack overflow as well and have yet to get an answer.

    https://stackoverflow.com/questions/65889545/running-powershell-script-from-within-c-sharp

    If you could help with this it would be greatly appreciated.

    Like

    • keithbabinec January 29, 2021 / 2:19 pm

      The command not found error means it can’t load the Az module. I would check to make sure that the computer running the application has the Az module installed either globally, or in the user profile context that that your .net core application runs.

      Like

  8. Neti May 18, 2021 / 1:15 am

    Thank you for this really good article. It was great help for us.

    We have implemented a .net core PowerShell host that uses the Microsoft.PowerShell.SDK, is delivered as a self-contained exe and can execute all kinds of PowerShell scripts allowed using our intern data exchange api, executed via the included PowerShell SDK. So far it works great.

    In our scripts we want to use the “ThreadJob” module, which does not work on our host because the PS module “ThreadJob” is not included in Microsoft.PowerShell.SDK.

    Now we’ve found a nuget package: ThreadJob 2.0

    Can we include this Nuget package in my host (in VisualStudio), and if so, how?

    How can we import (where is it physically) and deliver the module in our self-contained host?

    Like

    • keithbabinec May 18, 2021 / 9:44 pm

      If you need to import a PowerShell module (like ThreadJob), then that module must be installed on the target machine where your code will run. You don’t package the ThreadJob module with your application, so ignore any nuget packages, that will not help.

      You need to run the ‘Install-Module’ cmdlet at some point and install the dependency. So your application code can call ‘install-module’ the first time it runs, OR you can do it via some sort of setup/installation process.

      An even better option would be to just remove the dependency on ThreadJob entirely and manage the parallelism via runspace pools and execute multiple scripts at once.

      Like

  9. Nilay July 1, 2021 / 9:08 am

    I am getting Following Error when i tried to run Powershell Script thru Web API. using above mentioned Method 1. I am able to Connect Azure AD with Credential if my Connect Azure AD command is in Main Script. it is not working if my Script is like below

    Error Message:”The term ‘Connect-AzureAD’ is not recognized as a name of a cmdlet, function, script file, or executable program.
    Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

    Script like this:
    Import-Module MSOnline
    Import-Module Microsoft.Online.Sharepoint.Powershell -DisableNameChecking
    Import-Module ExchangeOnlineManagement
    Import-Module MicrosoftTeams

    function BoardingOpenConnections() {
    $script:allConnectionsReady = $false
    # Connect To Azure
    $script:AzureConnected = $false
    write-host “Connecting to Azure AD”
    Connect-MSolService -Credential $scriptCred
    Connect-AzureAD -Credential $scriptCred
    $script:AzureConnected = $true

    # Connect to Exchange OnLine
    $script:ExchangeConnected = $false
    Connect-ExchangeOnline -Credential $scriptCred -ShowProgress $False
    $script:ExchangeConnected = $true
    $script:allConnectionsReady = $True
    }

    Try
    {

    BoardingOpenConnections()
    }
    catch
    {

    }
    finally
    {

    }

    Like

    • keithbabinec July 1, 2021 / 5:54 pm

      The “Connect-AzureAD” cmdlet is from the AzureAD module. I see your script installs other modules, but not AzureAD. This error means that either AzureAD is not installed in that system, or that module cannot be loaded (for other reasons).

      Like

      • Nilay July 2, 2021 / 11:17 am

        If i put the code of BoardingOpenConnections Function below Import Statement (Out side of Try catch ) then it is Connecting to Azure and MSolService but if i put same code inside Try catch blog then it is not working and throwing Error like ‘The term ‘Connect-MSolService’ is not recognized as the name of a cmdlet, function, script file,’

        Like

        • keithbabinec July 3, 2021 / 12:22 am

          If the modules are installed in the system, then they don’t appear to be loading. I would confirm that the modules you are using are supported/working in PowerShell Core. If you are still stuck after that, then I would try posting a question on the Microsoft support forums or StackOverflow.

          Like

  10. Carol August 26, 2021 / 12:56 pm

    Just wanted to say great article + answers, it helped me much, thanks a lot!

    Like

  11. Tony November 19, 2021 / 5:02 pm

    Very useful script. I’m using to pass command to the Online Exchange server. When I use
    for example the command New-DistributionGroup and pass the Name as a key in the parameter dictionary, it fires an error saying A mandatory parameter is required!

    No problem if I pass the parameters as inline text with the command.

    Thanks.

    Like

    • keithbabinec November 19, 2021 / 5:17 pm

      Hi Tony — one thing to try: in the parameter dictionary, did you pass in the dash (-) values? Maybe try removing those if you did. If that wasn’t the issue, then maybe post the full code example somewhere like stackoverflow and see if you can grab an answer there.

      Like

      • Tony November 19, 2021 / 5:22 pm

        I’m passing without dash.

        Like

  12. Steve Kerrick November 29, 2021 / 7:50 am

    I found this post and the associated sample code on github to be solid gold. I’m building a website to use for our support folks, and I want to leverage the PowerShell scripts our ProdOps folks have already written. It was surprisingly difficult to find a good example of how to do this. Microsoft’s examples use .Net6 but it’s too soon for our team to start using .Net6 and Visual Studio 2022.

    THANK YOU keithbabinec for the really excellent post.

    Like

  13. Kenneth Scott December 29, 2021 / 10:36 am

    Hi Keith, I’ve leveraged your examples to build my little SpecOps app (https://github.com/KennethScott/SpecOps) and everything works very well. I noticed recently though, after I upgraded everything to .NET6, I saw some subtle behavior differences around how Write-Output seems to be handled. Many commands that would normally send output back to the console via an output stream suddenly weren’t, and I’d have to explicitly pipe their output via “| Write-Information” (or whatever stream I wanted to use). Something as simple as:
    Get-Host | Select-Object Version
    Now needed to be:
    Get-Host | Select-Object Version | Write-Information

    I also noticed explicit calls to Write-Output simply don’t show in a stream at all.
    Write-Output “hello world”
    would need to become
    Write-Information “hello world”
    for example.

    I was just curious if you’ve upgraded any of your code to .NET6 and/or may have run across anything like this. I’m kind of scratching my head as to what changed that would explain this. Any ideas would be appreciated.

    Thanks!

    Like

    • keithbabinec December 31, 2021 / 1:00 pm

      I haven’t done any testing of this with .NET 6 yet, so thanks for the heads up! I will try to poke around a bit there in January and see what might need to change in the samples.

      Like

  14. Brad December 13, 2022 / 7:00 am

    does anyone know if it is possible to do this within Blazor (custom runspace pool)? Like if one were to want to build a self-service IT automation webapp that could be consumed by other teams performing repetitive tasks? But we want to re-use existing PowerShell code as much as possible.

    Like

  15. doug October 19, 2023 / 1:45 pm

    Keith,
    Does each line of a multi-line script need a newline for carriage return or the like?
    thanx,
    -Doug

    Liked by 1 person

    • keithbabinec October 19, 2023 / 10:02 pm

      Hi Doug– good question. Assuming you mean the string value you pass to .AddScript(), then yes. If you have a multi-line script, you should either insert newline characters OR add the semicolon character between lines (this is a line terminator in PowerShell).

      If you are dynamically building the script inside your C# code, one line at a time– the easiest way to handle this is via .NET’s StringBuilder class. You can call .AppendLine() on the StringBuilder object, which adds your text plus the environment specific new line values. Then pass the output of your string builder into PowerShell’s AddScript() method.

      If you are loading your script from a text file on disk, then whatever reads that file (for example .NET’s File class ReadAllText() method) should provide you with a string that already has the newline characters in it. Hope that helps.

      Like

Leave a comment