Gå til indhold
Docs Try Aspire
Docs Try

Multi-language integrations

Dette indhold er ikke tilgængeligt i dit sprog endnu.

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 TypeScript AppHost use. The same ATS metadata can support additional AppHost languages as they become available in future Aspire releases.

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 integration analyzer provides build-time validation that catches common export mistakes. Use either supported enablement path:

  • If your integration already references 📦 Aspire.Hosting, opt in by setting the EnableAspireIntegrationAnalyzers MSBuild property in your integration project.
  • Alternatively, reference the standalone 📦 Aspire.Hosting.Integration.Analyzers package with PrivateAssets="all" using the same Aspire package version. The standalone analyzer package applies automatically and doesn’t require the MSBuild property.
XML — MyIntegration.csproj
<PropertyGroup>
<EnableAspireIntegrationAnalyzers>true</EnableAspireIntegrationAnalyzers>
</PropertyGroup>

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.

Annotate your extension methods with [AspireExport] and use XML doc comments to document them for the generated TypeScript SDK:

C# — MyDatabaseBuilderExtensions.cs
/// <summary>Adds a MyDatabase container resource.</summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The MyDatabase resource name.</param>
/// <param name="port">The optional port.</param>
/// <returns>The MyDatabase resource builder.</returns>
[AspireExport]
public static IResourceBuilder<MyDatabaseResource> AddMyDatabase(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
int? port = null)
{
// Your existing implementation...
}
/// <summary>Adds a database to the MyDatabase server.</summary>
/// <param name="builder">The MyDatabase server resource builder.</param>
/// <param name="name">The database name.</param>
/// <param name="databaseName">The optional database name override.</param>
/// <returns>The database resource builder.</returns>
[AspireExport]
public static IResourceBuilder<MyDatabaseDatabaseResource> AddDatabase(
this IResourceBuilder<MyDatabaseResource> builder,
[ResourceName] string name,
string? databaseName = null)
{
// Your existing implementation...
}
/// <summary>Adds a data volume to the MyDatabase server.</summary>
/// <param name="builder">The MyDatabase server resource builder.</param>
/// <param name="name">The optional volume name.</param>
/// <returns>The MyDatabase resource builder.</returns>
[AspireExport]
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 './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const db = await builder
.addMyDatabase('db', { port: 5432 })
.addDatabase('mydata')
.withDataVolume();
await builder.build().run();

XML doc comments are the primary source for generated SDK API documentation. The ATS scanner reads <summary>, <param>, <returns>, and <remarks> tags and includes them in the TypeScript JSDoc generated for your integration.

Use ats-* override tags when the standard C# XML documentation doesn’t translate well to generated SDK docs — for example, when a <summary> references C#-specific types or language constructs that have no direct equivalent in TypeScript. The supported overrides are:

  • <ats-summary> — overrides <summary> in polyglot docs
  • <ats-param name="..."> — overrides <param> for a specific parameter
  • <ats-returns> — overrides <returns>
  • <ats-remarks> — overrides <remarks>

An empty ats-* tag intentionally suppresses the matching standard documentation in the generated SDK.

C# — Polyglot-incompatible summary with ats-summary override
/// <ats-summary>Adds a Redis container resource.</ats-summary>
/// <summary>
/// Adds a <see cref="RedisResource"/> to the <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The Redis resource name.</param>
/// <returns>The Redis resource builder.</returns>
[AspireExport]
public static IResourceBuilder<RedisResource> AddRedis(
this IDistributedApplicationBuilder builder,
string name)
{
// ...
}

Use <ats-see cref="!:kind:identifier.path" /> and <ats-seealso cref="!:kind:identifier.path" /> to link to generated SDK elements. The !: prefix prevents the C# compiler from validating the custom cref format; ATS removes it before parsing. The supported kind values are type, method, and field. The reference path uses dot notation over generated SDK identifiers:

C# — Cross-references using ats-see
/// <summary>
/// Configures <ats-see cref="!:type:RedisResource" />.
/// </summary>
/// <remarks>
/// See also <ats-see cref="!:method:RedisResource.withPersistence" /> and
/// <ats-seealso cref="!:field:RedisDefaults.Port" />.
/// </remarks>
[AspireExport]
public static IResourceBuilder<RedisResource> ConfigureRedis(
this IDistributedApplicationBuilder builder,
string name)
{
// ...
}

TypeScript renders these references as JSDoc {@link ...} links.

The runtime dispatches TypeScript AppHost 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, public instance members also generate capabilities. 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. Inspect the generated SDK too; not every exposed-method naming edge case is reported as a generated member-name diagnostic.

Use these patterns as an implementation checklist when you design a hosting integration that should feel natural in both C# AppHosts and generated SDKs such as TypeScript:

  1. Design the ATS contract first: the generated methods, DTOs, values, callbacks, and docs that SDK users should see.
  2. Keep the C# API ergonomic with overloads and rich implementation types where they help C# callers.
  3. Adapt the C# API to ATS with explicit exports, DTO/options types, unions, small context/editor types, and ignored C#-only overloads.
  4. Validate the generated TypeScript SDK as the first concrete generated SDK, but keep the contract language-neutral so future SDKs can share it.
  5. Inspect generated signatures and docs before shipping.
DoDon’t
Design the ATS contract first, then map it back to C#.Export every C# overload and rely on renamed generated methods to make the API usable.
Keep runtime capability IDs unique and use MethodName only when the generated method name needs to differ.Reuse explicit export IDs across methods or set explicit IDs that duplicate the convention-derived name.
Use flat [AspireDto] options bags, init setters, arrays, records, and [AspireUnion] for cross-language inputs.Put runtime handles, delegates, loggers, configuration objects, service providers, or mutable framework types in DTO inputs.
Accept ATS values with union-shaped method parameters or editor methods.Hide live resource handles, endpoint references, or expressions inside JSON DTOs.
Use explicit [AspireExport] attributes for callback contexts and editor types.Turn on broad property or method expansion for large framework-style types.
Document every exported API, DTO, parameter, and property with language-neutral XML comments and ats-* overrides when needed.Publish C#-specific descriptions that mention implementation types the generated SDK doesn’t expose.
Test with the analyzer and a TypeScript AppHost project reference, then inspect generated .d.ts signatures.Assume a C# API shape is polyglot-safe without checking the generated module.

Start from the ATS contract you want generated SDKs to expose, then map that contract back to C#. TypeScript is a useful way to test and illustrate the shape today, but the design target is the ATS surface that other generated AppHost SDKs can share. Prefer one clear ATS-visible operation over a set of C# overloads that need different generated names. Use DTO options bags, [AspireUnion], and small exported context or editor types to represent the cross-language contract.

Every documented AppHost feature should have ATS metadata if generated SDKs can reasonably use it. For example, APIs for health checks, files, endpoints, commands, or resource annotations shouldn’t be documented as C#-only surface area unless the underlying feature is intentionally unavailable to generated SDKs.

Separate runtime IDs from friendly method names

Section titled “Separate runtime IDs from friendly method names”

Capability IDs are runtime dispatch identifiers. Keep them unique and stable. Avoid explicit export IDs when the convention-derived ID is already correct, and use MethodName only when the runtime ID must differ from the friendly generated SDK method name.

When several overloads describe one cross-language concept, prefer a single DTO-based overload, a union parameter, or a dispatcher-style export instead of many ATS-visible overloads with renamed methods. This usually produces a better generated API and avoids collisions.

C# — Unique capability IDs with friendly SDK names
[AspireExport("configureServer", MethodName = "configure")]
public static IResourceBuilder<MyServerResource> Configure(
this IResourceBuilder<MyServerResource> builder,
string value) => builder;
[AspireExport("configureDatabase", MethodName = "configure")]
public static IResourceBuilder<MyDatabaseResource> Configure(
this IResourceBuilder<MyDatabaseResource> builder,
string value) => builder;
TypeScript — Friendly generated method names
await server.configure('server-value');
await database.configure('database-value');

ATS dispatch is not C# overload resolution. A capability ID identifies one callable operation at runtime; it doesn’t include the C# receiver type, parameter list, generic constraints, or overload signature. Design the exported surface as the API you want generated SDK users to call, then adapt to your C# overloads internally.

Use these rules for overload sets:

  • If overloads represent one user concept, export one ATS method and model the variation with a DTO/options object, [AspireUnion], or an internal dispatcher.
  • If overloads represent different user concepts on the same generated target type, give them distinct export IDs and distinct generated method names. Don’t rely on C# signatures to disambiguate.
  • Use MethodName only when the runtime export ID must be unique but the generated methods live on different target types and can safely share the same friendly name.
  • Mark C#-only overloads with [AspireExportIgnore], especially overloads using interpolated string handlers, non-serializable callbacks or contexts, opaque provisioning types, or convenience overloads that are better represented by one ATS-friendly DTO or union method.

For one user concept, C# can keep convenience overloads, but export one generated shape:

C# — One exported shape for one concept
[AspireExportIgnore(Reason = "Use the ATS-friendly withInput export.")]
public static IResourceBuilder<T> WithInput<T>(
this IResourceBuilder<T> builder,
string value)
where T : IResource => WithInputCore(builder, value);
[AspireExportIgnore(Reason = "Use the ATS-friendly withInput export.")]
public static IResourceBuilder<T> WithInput<T>(
this IResourceBuilder<T> builder,
EndpointReference endpoint)
where T : IResource => WithInputCore(builder, endpoint);
[AspireExport("withInput")]
internal static IResourceBuilder<T> WithInputForExport<T>(
this IResourceBuilder<T> builder,
[AspireUnion(
typeof(string),
typeof(EndpointReference),
typeof(ReferenceExpression))]
object value)
where T : IResource => WithInputCore(builder, value);

Export adapter members can be internal when they exist only to shape the generated SDK. The ATS metadata still exposes the generated method, while the helper stays out of the public C# API.

The generated SDK stays focused on one concept:

TypeScript — One generated method
await resource.withInput('literal-value');
await resource.withInput(api.getEndpoint('https'));

For overload families that vary by optional configuration, prefer one flat options object:

C# — Options object for overload families
[AspireDto]
public sealed class PublishOptions
{
public string? Tag { get; init; }
public bool? Push { get; init; }
}
[AspireExport("publishAsImage")]
internal static IResourceBuilder<T> PublishAsImageForExport<T>(
this IResourceBuilder<T> builder,
PublishOptions? options = null)
where T : IResource
{
return PublishAsImageCore(builder, options);
}
TypeScript — Flat options object
await container.publishAsImage({ tag: 'v1', push: true });

Avoid generating overload-shaped APIs such as publishAsImageWithTag, publishAsImageWithPush, or nested bags when the variation is optional configuration.

For different concepts on the same target type, use different generated method names:

C# — Distinct concepts on one target
[AspireExport]
public static IResourceBuilder<MyResource> WithHttpEndpoint(
this IResourceBuilder<MyResource> builder,
int? port = null) => builder;
[AspireExport]
public static IResourceBuilder<MyResource> WithGrpcEndpoint(
this IResourceBuilder<MyResource> builder,
int? port = null) => builder;
TypeScript — Distinct generated methods
await service.withHttpEndpoint({ port: 8080 });
await service.withGrpcEndpoint({ port: 9090 });

Don’t use MethodName to give multiple exports the same generated method name on the same target type. That creates generated SDK member collisions even if the runtime capability IDs are unique. If the generated target type is the same, choose distinct generated method names or collapse the overloads into one union or DTO-shaped export.

Treat ExposeProperties = true and ExposeMethods = true as broad expansion switches. They export every compatible public member on the type, including inherited members where applicable. That is convenient for small purpose-built handle or context types, but risky on broad framework-style types because generated SDK member names can collide with extension methods or with other exposed members.

Prefer explicit [AspireExport] attributes on callback context and editor types, and use [AspireExportIgnore] for members that are C#-only, internal implementation details, or not useful in generated SDKs.

C# — Explicit callback context exports
[AspireExport]
public sealed class MyCallbackContext(
IResource resource,
Dictionary<string, object> environmentVariables,
IServiceProvider services)
{
[AspireExport]
public IResource Resource => resource;
[AspireExport]
internal EnvironmentEditor Environment => new(environmentVariables);
[AspireExportIgnore(Reason = "Implementation detail; not useful in generated SDKs.")]
public IServiceProvider Services => services;
}
C# — Avoid broad expansion on large contexts
// Avoid on broad context types unless every public member is intended for SDK users.
[AspireExport(ExposeProperties = true, ExposeMethods = true)]
public sealed class MyLargeContext
{
// ...
}

Generated member naming must be checked per generated target type, not just by runtime capability ID. Collisions can happen between exposed properties and exported extension methods. Inherited exposed properties also count, and common names such as name, command, or workingDirectory can collide with integration-specific methods. Read-only or init-only C# properties should be readable in generated SDKs, but shouldn’t expand into setters.

Some APIs need to accept values that aren’t plain JSON: literals, endpoint references, resource references, parameters, connection strings, or expressions. Model these as ATS value inputs on exported methods or editor methods with [AspireUnion]; don’t bury them inside DTOs, because DTOs are JSON-shaped input objects.

C# — ATS value input
[AspireExport]
public static IResourceBuilder<T> WithSetting<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
{
return WithSettingCore(builder, name, value);
}
TypeScript — Passing ATS values
await resource.withSetting('MODE', 'debug');
await resource.withSetting('UPSTREAM_URL', api.getEndpoint('https'));
await resource.withSetting('ConnectionStrings__db', database);

Use [AspireValue] value catalogs for predefined constants, such as model names or regions. Use [AspireUnion] parameters when callers need to pass live AppHost values.

DTOs are input objects, not live resource handles. Keep [AspireDto] types JSON-serializable, use init setters for input properties, and model collections as value-shaped arrays or records. Avoid exposing runtime handle-backed types, mutable framework collections, delegates, loggers, configuration objects, or service-provider types through DTOs.

For an API with one optional options DTO, prefer a flat generated call shape such as addMyResource('name', { port: 5432 }) instead of requiring { options: { port: 5432 } }. If an existing C# API uses a rich model type that doesn’t serialize cleanly, add a small DTO or options type for the exported API.

C# — Flat options DTO
[AspireDto]
public sealed class AddMyWorkerOptions
{
public string? ImageTag { get; init; }
public string[] Args { get; init; } = [];
}
[AspireExport]
public static IResourceBuilder<MyWorkerResource> AddMyWorker(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
AddMyWorkerOptions? options = null)
{
var resource = new MyWorkerResource(
name,
options?.ImageTag,
options?.Args ?? []);
return builder.AddResource(resource);
}
TypeScript — Flat generated call shape
const worker = await builder.addMyWorker('worker', {
imageTag: '1.2.3',
args: ['--verbose'],
});

Collection-valued DTO inputs should also feel like plain JSON in the generated SDK:

C# — Collection-valued DTO input
[AspireDto]
public sealed class AccessRuleOptions
{
public string[] AddressPrefixes { get; init; } = [];
public Dictionary<string, string> Tags { get; init; } = [];
}
TypeScript — Plain JSON DTO input
await resource.withAccessRule({
addressPrefixes: ['10.0.0.0/24'],
tags: { environment: 'dev' },
});

Generated SDK callbacks can call back into ATS/RPC; in TypeScript, that commonly means callbacks can await generated SDK calls. Keep callback context types small, expose only the members the callback needs, and prefer editor objects over raw mutable collections.

If an exported method invokes a synchronous delegate inline, set RunSyncOnBackgroundThread = true so nested callback responses can continue flowing. This includes async-returning exported methods that call the synchronous delegate before their first await. When possible, design callback APIs so they don’t re-enter RPC on a blocked thread.

C# — Inline callback invocation
[AspireExport(RunSyncOnBackgroundThread = true)]
public static IResourceBuilder<MyWorkerResource> WithConfiguration(
this IResourceBuilder<MyWorkerResource> builder,
Action<MyConfigurationEditor> configure)
{
var editor = new MyConfigurationEditor();
configure(editor);
return builder.WithEnvironment("MY_WORKER_CONFIG", editor.ToJson());
}

Treat XML doc comments as part of the ATS contract. Document exported methods, DTOs, parameters, properties, and value catalogs because generated SDK docs come from these comments. Keep the wording language-neutral and describe the generated contract rather than C#-specific implementation details. Use ats-* override tags when a standard XML comment needs a generated-SDK-specific description; the TypeScript SDK renders the resulting docs as JSDoc.

C# — Language-neutral export docs
[AspireDto]
public sealed class AddMyDatabaseDocsOptions
{
/// <summary>The database engine version.</summary>
public string? Version { get; init; }
}
/// <summary>Adds a MyDatabase resource.</summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The resource name.</param>
/// <param name="options">Optional database configuration.</param>
/// <returns>The MyDatabase resource builder.</returns>
[AspireExport]
public static IResourceBuilder<MyDatabaseResource> AddMyDatabase(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
AddMyDatabaseDocsOptions? options = null)
{
return builder.AddResource(new MyDatabaseResource(name, options));
}

Use the analyzer and a TypeScript AppHost that references your integration project. After aspire restore or aspire run generates modules, inspect the generated imports and .d.ts signatures to confirm method names, DTO shapes, callback accessors, and docs metadata match the API you intended. Generated TypeScript APIs are promise-heavy, so await builder and fluent calls, await PropertyAccessor reads and writes, and consult the generated .d.ts signatures for service-provider methods and callback accessors. New TypeScript AppHosts use apphost.mts and .aspire/modules/*.mjs imports; legacy apphost.ts projects remain supported and may use .modules/*.js imports.

TypeScript — Await generated AppHost APIs
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const worker = await builder.addMyWorker('worker');
await worker.withConfiguration(async (config) => {
await config.set('mode', 'debug');
});
await builder.build().run();

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 getter-only properties, mutable collection wrapper properties, and scalar or wrapper read-write properties:

  • Getter-only properties are generated as async methods in TypeScript: property(): Promise<T>. This includes getter-only AspireList<T> and AspireDict<K,V> properties.
  • Mutable collection wrapper properties (such as AspireList<T> or AspireDict<K,V> with a setter) are generated as readonly synchronous wrapper properties.
  • Scalar or wrapper read-write properties are generated as property accessors with async get() and set(value) functions.

Read-only or init-only C# properties can be readable in generated SDKs, but they shouldn’t expose TypeScript setters. If TypeScript callers need to mutate state, export an explicit method or editor type instead.

For example, a C# class with all three property shapes:

C# — Mixed property kinds
[AspireExport(ExposeProperties = true)]
public class MyPropertyDemo
{
/// <summary>Getter-only — becomes an async method in TypeScript.</summary>
public IResource Resource => _resource;
/// <summary>Getter-only collection — also becomes an async method in TypeScript.</summary>
public AspireList<string> DefaultTags { get; } = new();
/// <summary>Mutable collection wrapper — stays a readonly wrapper in TypeScript.</summary>
public AspireList<string> Tags { get; set; } = new();
/// <summary>Scalar read-write value — becomes a property accessor.</summary>
public string DisplayName { get; set; } = "";
}

Generates the following TypeScript interface:

TypeScript — Generated interface
export interface MyPropertyDemo {
toJSON(): MarshalledHandle;
resource(): Promise<IResourceHandle>; // getter-only → async method
defaultTags(): Promise<AspireList<string>>; // getter-only collection → async method
readonly tags: AspireList<string>; // mutable collection wrapper → readonly property
readonly displayName: PropertyAccessor<string>; // scalar read-write → async get/set accessor
}

TypeScript AppHost authors call getter-only properties as functions:

TypeScript — Consuming the generated API
const resource = await context.resource();
const defaultTags = await context.defaultTags();
const tags = context.tags; // no await needed for mutable collection wrappers
const displayName = await context.displayName.get();
await context.displayName.set('api');

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 ATS 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 generated SDK 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>
/// <param name="name">The environment variable name.</param>
/// <param name="value">The environment variable value.</param>
[AspireExport]
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 generated SDK 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]
public IResource Resource => resource;
/// <summary>Gets the execution context.</summary>
[AspireExport]
public DistributedApplicationExecutionContext ExecutionContext => executionContext;
/// <summary>Gets the environment variable editor.</summary>
[AspireExport]
internal EnvironmentEditor Environment => new(environmentVariables);
}

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

C# — MyResourceBuilderExtensions.cs
/// <summary>Configures the resource using a callback.</summary>
/// <param name="builder">The resource builder.</param>
/// <param name="configure">The callback to configure the resource.</param>
/// <returns>The resource builder.</returns>
[AspireExport]
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');
});

Services available from callback service providers

Section titled “Services available from callback service providers”

Several ATS callback contexts expose an IServiceProvider handle through a services() or serviceProvider() accessor. These accessors are async PropertyAccessor values, so await them before using the returned service provider. For example, resource events expose await event.services(), command contexts expose await context.serviceProvider(), and DistributedApplicationExecutionContext exposes await executionContext.serviceProvider().

In TypeScript, use the ATS-friendly methods on the service provider instead of the C# generic dependency-injection pattern. For example, call services.getLoggerFactory() instead of serviceProvider.GetRequiredService<ILoggerFactory>().

TypeScript — Using a callback service provider
await builder.subscribeBeforeStart(async (event) => {
const services = await event.services();
const loggerFactory = services.getLoggerFactory();
const logger = loggerFactory.createLogger('AppHost');
logger.logInformation('The AppHost is starting.');
});

The generated TypeScript SDK exposes these service-provider methods:

Use getDistributedApplicationModel() when callback code needs to inspect or locate resources in the AppHost model:

TypeScript — Inspecting AppHost resources
await builder.subscribeAfterResourcesCreated(async (event) => {
const services = await event.services();
const model = services.getDistributedApplicationModel();
const resources = model.getResources();
const api = model.findResourceByName('api');
});

Use getLoggerFactory() to create loggers from callbacks that expose a service provider:

TypeScript — Writing logs from a callback
await builder.subscribeBeforeStart(async (event) => {
const services = await event.services();
const logger = services.getLoggerFactory().createLogger('startup');
logger.logInformation('Preparing resources.');
});

Use getResourceNotificationService() for resource state transitions and getResourceLoggerService() for resource log streams:

TypeScript — Waiting for resource state
const cache = await builder.addRedis('cache');
cache.onResourceReady(async (event) => {
const resource = await event.resource();
const services = await event.services();
const resourceName = resource.getResourceName();
const notifications = services.getResourceNotificationService();
const resourceLogs = services.getResourceLoggerService();
notifications.waitForResourceHealthy(resourceName);
resourceLogs.completeLog(resource);
});

Use getUserSecretsManager() for user secrets and getAspireStore() for stable files created during AppHost execution:

TypeScript — Using AppHost services
await builder.subscribeBeforeStart(async (event) => {
const services = await event.services();
const secrets = services.getUserSecretsManager();
const store = services.getAspireStore();
const model = services.getDistributedApplicationModel();
const api = model.findResourceByName('api');
if (await secrets.isAvailable()) {
secrets.getOrSetSecret(api, 'ApiKey', crypto.randomUUID());
}
const generatedFile = store.getFileNameWithContent(
'seed-data.json',
'./seed-data.json'
);
});

Use getEventing() when callback code needs the eventing service itself:

TypeScript — Using eventing from services
const subscription = await builder.subscribeBeforeStart(async (event) => {
const services = await event.services();
const eventing = services.getEventing();
eventing.unsubscribe(subscription);
});

Use getResourceCommandService() when a callback needs to execute another resource command. Resolve the command service from the AppHost execution context’s service provider before registering the command, then capture it in the callback:

TypeScript — Executing a command from a callback
const executionContext = await builder.executionContext();
const serviceProvider = await executionContext.serviceProvider();
const commandService = serviceProvider.getResourceCommandService();
const cache = await builder.addRedis('cache');
await cache.withCommand('clear-cache', 'Clear Cache', async () => {
return { success: true };
});
const api = await builder.addProject('api', '../Api/Api.csproj');
await api.withCommand('reset-all', 'Reset Everything', async (ctx) => {
const cancellationToken = await ctx.cancellationToken();
return commandService.executeCommandAsync(cache, 'clear-cache', {
arguments: { requestedBy: 'reset-all' },
cancellationToken,
});
});

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; }
}

DTO collection properties (such as List<T>, IList<T>, IEnumerable<T>, arrays, or Dictionary<string, T>) are generated as value-shaped JSON types in the TypeScript SDK. This means AppHost code can supply plain collection literals directly rather than constructing handle-backed wrapper objects.

For example, a DTO that accepts address prefix and fully qualified domain name collections:

C# — MyAccessRule.cs
[AspireDto]
public sealed class MyAccessRule
{
public List<string> AddressPrefixes { get; init; } = [];
public List<string> FullyQualifiedDomainNames { get; init; } = [];
}

The TypeScript SDK exposes collection values using ordinary TypeScript collection shapes:

TypeScript — apphost.mts
const rule: MyAccessRule = {
addressPrefixes: ['203.0.113.0/24', '198.51.100.0/24'],
fullyQualifiedDomainNames: ['example.com'],
};

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 TypeScript AppHost authors should be able to reference without reconstructing them manually.

Apply [AspireValue] to public static fields (prefer static readonly) or public static properties with a public static getter. 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 the TypeScript SDK. The nesting mirrors the static class hierarchy of the C# source:

TypeScript — apphost.mts
import { createBuilder } from './.aspire/modules/aspire.mjs';
import { MyModels } from './.aspire/modules/my-integration.mjs';
const builder = await createBuilder();
// Use predefined catalog values directly
await builder.addMyService('svc', { model: MyModels.FastModels.Lite });
await builder.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 members must be public static fields or properties with a public static getter. Indexed properties aren’t supported, and non-public members are skipped with a warning.
  • Catalog paths (catalogName, nested static type names, and Name) must be valid generated SDK identifiers: an ASCII letter or _ first, followed by ASCII letters, digits, or _.
  • Duplicate catalog paths and parent/leaf path conflicts are skipped.
  • Values must be copied shapes supported by the scanner: primitives, enums, arrays, read-only dictionaries, and DTOs that recursively contain only supported copied shapes.
  • Don’t use mutable List<T> or Dictionary<K,V>, handles (IResourceBuilder<T>, resource instances), delegates, runtime state, or DTOs containing unsupported shapes. These are skipped even when System.Text.Json could serialize them.
  • 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 generated SDKs (for example, interpolated string handlers, non-serializable callback contexts, or C#-specific types). Mark these with [AspireExportIgnore]:

C# — Exclude incompatible overloads
// This overload works in generated SDKs — simple parameters
/// <summary>Sets the maximum number of connections.</summary>
/// <param name="builder">The resource builder.</param>
/// <param name="maxConnections">The maximum number of connections.</param>
/// <returns>The resource builder.</returns>
[AspireExport]
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
/// <summary>Sets an environment variable on the resource.</summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The environment variable name.</param>
/// <param name="value">The value, which may be a string, reference expression, or endpoint reference.</param>
/// <returns>The resource builder.</returns>
[AspireExport]
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(IResourceBuilder<ExternalServiceResource>),
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 integration analyzer reports these diagnostics when it’s enabled:

IDSeverityDescription
ASPIREEXPORT001ErrorStandalone [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
ASPIREEXPORT014ErrorDuplicate generated polyglot member name on the same generated SDK target type; use a unique MethodName or combine overloads
ASPIREEXPORT015Error[AspireExport(Description = ...)] is deprecated — use XML doc comments instead
ASPIREEXPORT016WarningDTO property is a get-only mutable collection. Add an init accessor

A clean build with zero analyzer warnings or errors is the required baseline. Still validate the generated module and signatures before shipping the integration.

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.mts",
"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 .aspire/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.mts:

    TypeScript — apphost.mts
    import { createBuilder } from './.aspire/modules/aspire.mjs';
    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 exports that invoke synchronous callbacks inline, including async-returning exports that call callbacks before their first await)
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].