Skip to content
Docs Try Aspire
Docs Try

Multi-language integrations

Preview feature

Aspire hosting integrations are C# libraries that extend the AppHost with new resource types. By default, these integrations are only available in C# AppHosts. To make them available in TypeScript AppHosts, you annotate your APIs with ATS (Aspire Type System) attributes.

This guide walks you through the process of exporting your integration for multi-language use.

When a TypeScript AppHost adds your integration, the Aspire CLI:

  1. Loads your integration assembly
  2. Scans for ATS attributes on methods, types, and properties, such as [AspireExport].
  3. Generates a typed TypeScript SDK with matching methods
  4. The generated SDK communicates with your C# code via JSON-RPC at runtime

Your C# code runs as-is — the TypeScript SDK is a thin client that calls into it. You don’t need to rewrite anything in TypeScript.

The 📦 Aspire.Hosting.Integration.Analyzers package provides build-time validation that catches common export mistakes. Add it to your integration project:

XML — MyIntegration.csproj
<PackageReference Include="Aspire.Hosting.Integration.Analyzers" Version="13.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

The analyzer reports diagnostics that help you get your exports right before users encounter runtime errors. Common scenarios include detecting incompatible parameter types, missing export annotations on public methods, duplicate export or capability IDs, and synchronous callbacks that could deadlock in multi-language app hosts.

Suppress the experimental diagnostic in your project file:

XML — MyIntegration.csproj
<PropertyGroup>
<NoWarn>$(NoWarn);ASPIREATS001</NoWarn>
</PropertyGroup>

Then annotate your extension methods with [AspireExport]:

C# — MyDatabaseBuilderExtensions.cs
[AspireExport("addMyDatabase", Description = "Adds a MyDatabase container resource")]
public static IResourceBuilder<MyDatabaseResource> AddMyDatabase(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
int? port = null)
{
// Your existing implementation...
}
[AspireExport("addDatabase", Description = "Adds a database to the MyDatabase server")]
public static IResourceBuilder<MyDatabaseDatabaseResource> AddDatabase(
this IResourceBuilder<MyDatabaseResource> builder,
[ResourceName] string name,
string? databaseName = null)
{
// Your existing implementation...
}
[AspireExport("withDataVolume", Description = "Adds a data volume to the MyDatabase server")]
public static IResourceBuilder<MyDatabaseResource> WithDataVolume(
this IResourceBuilder<MyDatabaseResource> builder,
string? name = null)
{
// Your existing implementation...
}

This generates the following highlighted TypeScript APIs:

TypeScript — Generated SDK
import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const db = await builder
.addMyDatabase("db", { port: 5432 })
.addDatabase("mydata")
.withDataVolume();
const app = await builder.build();
await app.run();

The runtime dispatches multi-language calls by capability ID. A capability ID is more restrictive than a C# method signature: it doesn’t include the C# receiver type, parameter list, or overload signature. Starting in Aspire 13.3, the analyzer reports ASPIREEXPORT013 when two exports in the same assembly generate the same capability ID.

For static exports, the generated capability ID uses the assembly name and effective export ID. The following methods collide even though they target different C# types:

C# — Duplicate capability ID
[AspireExport("configure")]
public static void ConfigureBuilder(
this IDistributedApplicationBuilder builder,
string name)
{
// ...
}
[AspireExport("configure")]
public static IResourceBuilder<MyDatabaseResource> ConfigureDatabase(
this IResourceBuilder<MyDatabaseResource> builder,
string value)
{
return builder;
}

Use distinct export IDs for the runtime capability, and use MethodName when you want the generated SDK method name to stay concise on different target types:

C# — Unique capability IDs with SDK method names
[AspireExport("configureBuilder", MethodName = "configure")]
public static void ConfigureBuilder(
this IDistributedApplicationBuilder builder,
string name)
{
// ...
}
[AspireExport("configureDatabase", MethodName = "configure")]
public static IResourceBuilder<MyDatabaseResource> ConfigureDatabase(
this IResourceBuilder<MyDatabaseResource> builder,
string value)
{
return builder;
}

When you set ExposeMethods = true or ExposeProperties = true on a context type, the analyzer also checks the capabilities generated for public instance members. Overloaded instance methods with the same name generate the same default capability ID, so either expose a single ATS-friendly overload, mark unsupported overloads with [AspireExportIgnore], or give each exported member a unique [AspireExport] ID and generated MethodName.

Mark your resource types with [AspireExport] so the TypeScript SDK can reference them as typed handles. Set ExposeProperties = true to make all public properties accessible as capabilities, or annotate individual properties with [AspireExport] for fine-grained control:

C# — MyDatabaseResource.cs
[AspireExport(ExposeProperties = true)]
public sealed class MyDatabaseResource(string name)
: ContainerResource(name), IResourceWithConnectionString
{
/// <summary>
/// Gets the primary endpoint for the database.
/// </summary>
public EndpointReference PrimaryEndpoint => new(this, "tcp");
/// <summary>
/// Internal implementation detail — not exported.
/// </summary>
[AspireExportIgnore]
public string InternalConnectionPool { get; set; } = "";
}
[AspireExport]
public sealed class MyDatabaseDatabaseResource(string name, MyDatabaseResource parent)
: Resource(name)
{
// Your existing implementation...
}

When ExposeProperties = true, each public property becomes a capability in the generated SDK. Use [AspireExportIgnore] on properties that shouldn’t be exposed.

You can also set ExposeMethods = true to export public instance methods as capabilities alongside properties.

How getter-only properties appear in TypeScript

Section titled “How getter-only properties appear in TypeScript”

The code generator distinguishes between read-only (getter-only) properties and read-write or mutable-collection properties:

  • Getter-only properties (no setter, and not a mutable collection type) are generated as async methods in TypeScript: property(): Promise<T>.
  • Read-write properties and mutable-collection properties (such as AspireList<T> or AspireDict<K,V>) are generated as readonly getter properties.

For example, a C# class with both kinds of property:

C# — Mixed property kinds
[AspireExport(ExposeProperties = true)]
public class MyCallbackContext
{
/// <summary>Getter-only — becomes an async method in TypeScript.</summary>
public IResource Resource => _resource;
/// <summary>Mutable collection — stays a readonly getter in TypeScript.</summary>
public AspireList<string> Tags { get; } = new();
}

Generates the following TypeScript interface:

TypeScript — Generated interface
export interface MyCallbackContext {
toJSON(): MarshalledHandle;
resource(): Promise<IResourceHandle>; // getter-only → async method
readonly tags: AspireList<string>; // mutable collection → readonly getter
}

TypeScript AppHost authors call getter-only properties as functions:

TypeScript — Consuming the generated API
const resource = await context.resource();
const tags = context.tags; // no await needed for mutable collections

Callback context types and the ATS-first editor pattern

Section titled “Callback context types and the ATS-first editor pattern”

When you export a method that accepts a callback (such as withEnvironmentCallback, withArgsCallback, or withUrls), the callback receives a context object. For TypeScript compatibility, context types should follow the ATS-first design:

  1. Use [AspireExport] (not ExposeProperties = true) on the context class.
  2. Annotate only the properties that TypeScript callers need with individual [AspireExport] attributes.
  3. For mutable state (environment variables, command-line arguments, URL lists), expose a small editor class rather than the raw collection.

An editor wraps a mutable collection and exposes specific operations — typically add, set, or remove — instead of handing the raw collection to TypeScript:

C# — EnvironmentEditor.cs
/// <summary>
/// Provides an ATS-first editor for environment variables within polyglot callbacks.
/// </summary>
[AspireExport]
internal sealed class EnvironmentEditor(Dictionary<string, object> environmentVariables)
{
/// <summary>Sets an environment variable.</summary>
[AspireExport(Description = "Sets an environment variable")]
public void Set(
string name,
[AspireUnion(
typeof(string),
typeof(ReferenceExpression),
typeof(EndpointReference),
typeof(IResourceBuilder<ParameterResource>),
typeof(IResourceBuilder<IResourceWithConnectionString>))]
object value)
{
environmentVariables[name] = value;
}
}

Use individual [AspireExport] attributes on each property the TypeScript caller needs. Pass the editor as a getter-only property so TypeScript callers receive it as an async method:

C# — MyCallbackContext.cs
[AspireExport]
public sealed class MyCallbackContext(
DistributedApplicationExecutionContext executionContext,
IResource resource,
Dictionary<string, object> environmentVariables)
{
/// <summary>Gets the resource associated with this callback.</summary>
[AspireExport(Description = "Gets the resource associated with this callback")]
public IResource Resource => resource;
/// <summary>Gets the execution context.</summary>
[AspireExport(Description = "Gets the execution context")]
public DistributedApplicationExecutionContext ExecutionContext => executionContext;
/// <summary>Gets the environment variable editor.</summary>
[AspireExport(Description = "Gets the environment variable editor")]
internal EnvironmentEditor Environment => new(environmentVariables);
}

Export the extension method that accepts the callback, using Action<MyCallbackContext> as the parameter type:

C# — MyResourceBuilderExtensions.cs
[AspireExport("withMyCallback", Description = "Configures the resource using a callback")]
public static IResourceBuilder<MyResource> WithMyCallback(
this IResourceBuilder<MyResource> builder,
Action<MyCallbackContext> configure)
{
return builder.WithAnnotation(new MyCallbackAnnotation(configure));
}

The generated TypeScript API accepts an async arrow function:

TypeScript — Consuming the callback API
await myResource.withMyCallback(async (context) => {
const resource = await context.resource();
const env = await context.environment();
await env.set("MY_KEY", "my-value");
});

If your integration accepts structured configuration, mark the options class with [AspireDto]. DTOs are serialized as JSON between the TypeScript AppHost and the .NET runtime:

C# — MyDatabaseOptions.cs
[AspireDto]
public sealed class AddMyDatabaseOptions
{
public required string Name { get; init; }
public int? Port { get; init; }
public string? ImageTag { get; init; }
}

Use [AspireValue] to export immutable predefined values from your integration into guest SDKs as typed catalog objects. This is useful when your integration ships well-known constants or configuration presets—such as a list of supported model names or region identifiers—that polyglot AppHost authors should be able to reference without reconstructing them manually.

[AspireValue] is an experimental API protected by the ASPIREATS001 diagnostic. Suppress it in your project file along with the other ATS attributes:

XML — MyIntegration.csproj
<PropertyGroup>
<NoWarn>$(NoWarn);ASPIREATS001</NoWarn>
</PropertyGroup>

Apply [AspireValue] to static readonly fields or static properties on your type. The required catalogName argument sets the root name of the generated catalog in guest SDKs:

C# — MyModels.cs
using Aspire.Hosting;
[AspireDto]
public sealed class MyModel
{
public required string Name { get; init; }
public required string Version { get; init; }
}
public static class MyModels
{
public static class FastModels
{
/// <summary>A fast, lightweight model for simple tasks.</summary>
[AspireValue("MyModels")]
public static readonly MyModel Lite = new() { Name = "my-model-lite", Version = "1" };
/// <summary>A fast model with extended context support.</summary>
[AspireValue("MyModels")]
public static readonly MyModel LiteLong = new() { Name = "my-model-lite-long", Version = "1" };
}
public static class PowerModels
{
/// <summary>A high-capability model for complex tasks.</summary>
[AspireValue("MyModels")]
public static readonly MyModel Pro = new() { Name = "my-model-pro", Version = "2" };
}
}

The scanner snaps the values at scan time by serializing each field or property to JSON. It also reads XML doc comments to include descriptions in the generated catalog.

After generating the SDK (for example, with aspire run), the catalog is available as a nested object in each supported language. The nesting mirrors the static class hierarchy of the C# source:

TypeScript — apphost.ts
import { createBuilder } from './.modules/aspire.js';
import { MyModels } from './.modules/my-integration.js';
const builder = await createBuilder();
// Use predefined catalog values directly
await builder
.addMyService('svc', { model: MyModels.FastModels.Lite })
.build()
.run();
Python — apphost.py
from modules.my_integration import MyModels
builder = await create_builder()
await (
builder
.add_my_service("svc", model=MyModels.FastModels.Lite)
.build()
.run()
)

By default, the exported name matches the field or property name. Use the Name property to override it:

C# — Override exported name
[AspireValue("MyModels", Name = "lite")]
public static readonly MyModel Lite = new() { Name = "my-model-lite", Version = "1" };
  • Exported fields and properties must be static.
  • The value must be serializable to JSON. Avoid types that hold runtime handles, delegates, or other non-serializable state.
  • Handles (IResourceBuilder<T>, resource instances) are not valid as exported values.
  • Values are snapped once at scan time. They are emitted as compile-time constants in generated SDKs and are not refreshed at runtime.

Some C# overloads use types that can’t be represented in TypeScript (e.g., Action<T> delegates with non-serializable contexts, interpolated string handlers, or C#-specific types). Mark these with [AspireExportIgnore]:

C# — Exclude incompatible overloads
// This overload works in TypeScript — simple parameters
[AspireExport("withConnectionStringLimit", Description = "Sets connection limit")]
public static IResourceBuilder<MyDatabaseResource> WithConnectionStringLimit(
this IResourceBuilder<MyDatabaseResource> builder,
int maxConnections)
{
// ...
}
// This overload uses a C#-specific type — exclude it
[AspireExportIgnore(Reason = "ForwarderConfig is not ATS-compatible. Use the DTO-based overload.")]
public static IResourceBuilder<MyDatabaseResource> WithConnectionStringLimit(
this IResourceBuilder<MyDatabaseResource> builder,
ForwarderConfig config)
{
// ...
}

When a parameter accepts multiple types, use [AspireUnion] to declare the valid options:

C# — Union type parameter
[AspireExport("withEnvironment", Description = "Sets an environment variable")]
public static IResourceBuilder<T> WithEnvironment<T>(
this IResourceBuilder<T> builder,
string name,
[AspireUnion(
typeof(string),
typeof(ReferenceExpression),
typeof(EndpointReference),
typeof(IResourceBuilder<ParameterResource>),
typeof(IResourceBuilder<IResourceWithConnectionString>),
typeof(IExpressionValue))]
object value)
where T : IResourceWithEnvironment
{
// ...
}

All types in the union must be ATS-compatible. The analyzer (ASPIREEXPORT005, ASPIREEXPORT006) validates union declarations at build time.

The Aspire.Hosting.Integration.Analyzers package reports these diagnostics:

IDSeverityDescription
ASPIREEXPORT001Error[AspireExport] method must be static
ASPIREEXPORT002ErrorInvalid export ID format (must match [a-zA-Z][a-zA-Z0-9.]*)
ASPIREEXPORT003ErrorReturn type is not ATS-compatible
ASPIREEXPORT004ErrorParameter type is not ATS-compatible
ASPIREEXPORT005Warning[AspireUnion] requires at least 2 types
ASPIREEXPORT006WarningUnion type is not ATS-compatible
ASPIREEXPORT007WarningDuplicate export ID for the same target type
ASPIREEXPORT008WarningPublic extension method on exported type missing [AspireExport] or [AspireExportIgnore]
ASPIREEXPORT009WarningExport name may collide with other integrations
ASPIREEXPORT010WarningSynchronous callback invoked inline — may deadlock in multi-language app hosts
ASPIREEXPORT011WarningExplicit export ID matches the convention-derived name
ASPIREEXPORT012WarningCallback context type missing [AspireExport]
ASPIREEXPORT013WarningDuplicate polyglot capability ID across exports in the same assembly

A clean build with zero analyzer warnings means your integration is ready for multi-language use.

You can test your integration locally without publishing to a NuGet feed. In your TypeScript AppHost’s aspire.config.json, set the package value to a .csproj path instead of a version number:

JSON — aspire.config.json
{
"appHost": {
"path": "apphost.ts",
"language": "typescript/nodejs"
},
"packages": {
"Aspire.Hosting.Redis": "13.3.0",
"MyCompany.Hosting.MyDatabase": "../src/MyCompany.Hosting.MyDatabase/MyCompany.Hosting.MyDatabase.csproj"
}
}

When the CLI detects a .csproj path, it builds the project locally and generates the TypeScript SDK from the resulting assemblies. This lets you iterate on your exports without publishing to a feed.

  1. Create a TypeScript AppHost for testing:

    Create test AppHost
    mkdir test-apphost && cd test-apphost
    aspire init --language typescript
  2. Add your integration via project reference in aspire.config.json:

    JSON — aspire.config.json (packages section)
    {
    "packages": {
    "MyCompany.Hosting.MyDatabase": "../src/MyCompany.Hosting.MyDatabase/MyCompany.Hosting.MyDatabase.csproj"
    }
    }
  3. Run aspire run to generate the TypeScript SDK:

    Generate SDK and start
    aspire run
  4. Check the generated .modules/ directory for your integration’s TypeScript types. Verify that your exported methods appear with the correct signatures.

  5. Use the generated API in apphost.ts:

    TypeScript — apphost.ts
    import { createBuilder } from './.modules/aspire.js';
    const builder = await createBuilder();
    const db = await builder
    .addMyDatabase('db', { port: 5432 })
    .addDatabase('mydata')
    .withDataVolume();
    await builder.build().run();

The following types are ATS-compatible and can be used in exported method signatures:

CategoryTypes
Primitivesstring, bool, int, long, float, double, decimal
Value typesDateTime, TimeSpan, Guid, Uri
EnumsAny enum type
HandlesIResourceBuilder<T>, IDistributedApplicationBuilder, resource types marked with [AspireExport]
DTOsClasses/structs marked with [AspireDto]
Exported valuesStatic fields/properties marked with [AspireValue] (emitted as catalog constants in guest SDKs)
CollectionsList<T>, Dictionary<string, T>, arrays — where T is ATS-compatible
DelegatesAction<T>, Func<T>, and other delegate types (use RunSyncOnBackgroundThread = true for synchronous delegates invoked inline)
ServicesILogger, IServiceProvider, IConfiguration (already exported by the core framework)
SpecialParameterResource, ReferenceExpression, EndpointReference, IExpressionValue, CancellationToken
NullableAny of the above as nullable (T?)

Types that are not ATS-compatible include: interpolated string handlers and custom complex types without [AspireExport] or [AspireDto].