Skip to main content
0.0.x

Orchestration with Aspire

A NetScript app is never one process. This essay explains why it needs an orchestrator at all, how the orchestrator's resource graph is generated from your plugins rather than hand-wired, and what a single aspire start actually stands up — so you can reason about the running system instead of treating it as a black box.

The Aspire AppHost resource graph: appsettings.json and plugin contributions feed createNetScriptAppHost, which registers infrastructure (Postgres, Redis), services, plugin APIs, and background processors, with cross-references resolved into injected environment variables, all observed by the dashboard at :18888.
One aspire start derives a coherent resource graph from your plugins, resolves the wiring between resources, and surfaces the whole thing in a dashboard.

Why an orchestrator at all

Picture what "run my app locally" really means for a multi-plugin NetScript project. It is not a single server. It is a small fleet:

  • An example oRPC service (defineService) on its assigned port.
  • A Fresh dashboard app.
  • Each runtime plugin's HTTP APIworkers-api, sagas-api, triggers-api, auth-api (on their assigned high-range ports), the durable-streams runtime (on its assigned port).
  • Each plugin's isolated background processors — the workers and sagas runners, the triggers processor — running as separate executables, not threads inside the API.
  • The infrastructure all of those depend on: a database — Postgres (the recommended engine; or mysql / mssql / sqlite via --db) — and a shared cache — redis by default, or garnet / deno-kv via --cache-backend.

Standing that up by hand is the integration tax: start each process in dependency order, hand each one the right connection strings, teach each one where its neighbours live, and tear it all down cleanly afterwards. Do it wrong and you get the classic distributed-dev failure modes — a service that races ahead of its database, a processor pointed at the wrong cache, a plugin that cannot find the sibling it calls. Removing exactly that tax is the whole reason the framework adopts an orchestrator.

Aspire is the conductor. You describe the desired graph; a single command stands the whole thing up with the wiring resolved. The database URL the users service needs, the cache endpoint the workers runtime needs, the cross-reference one plugin holds to another — Aspire computes them and injects them as environment variables, so no process has to discover its neighbours at runtime.

The AppHost: a generated TypeScript program

The heart of orchestration is the AppHost — a small TypeScript/Node program scaffolded into the aspire/ subfolder at aspire/apphost.mts. It is the entry point aspire start executes. It is deliberately tiny: it builds an Aspire builder, hands it your project's appsettings.json, and runs the resulting graph.

// aspire/apphost.mts (generated by `netscript init`)
import { createBuilder } from './.aspire/modules/aspire.mjs';
import { createNetScriptAppHost } from './.helpers/index.mjs';

// 1. Build an Aspire builder (SDK modules restored by `aspire restore`).
const builder = await createBuilder();

// 2. Translate appsettings.json + plugin contributions into a resource
//    graph: db, cache, services, plugin APIs, background processors, apps.
await createNetScriptAppHost(builder, '../appsettings.json');

// 3. Build the graph and run it (dashboard + every resource).
await builder.build().run();
{
  "appHost": { "path": "apphost.mts", "language": "typescript/nodejs" },
  "sdk": { "version": "13.4.6" },
  "profiles": {
    "https": {
      "applicationUrl": "https://localhost:0;http://localhost:0",
      "environmentVariables": {
        "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:0",
        "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
        "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true",
        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:0"
      }
    }
  },
  "packages": {
    "Aspire.Hosting.PostgreSQL": "13.4.6",
    "Aspire.Hosting.Browsers": "13.4.6-preview.1.26319.6"
  }
}

Two facts about the AppHost are worth internalizing because they contradict assumptions people carry from .NET Aspire:

  1. It uses an isolated TypeScript/Node runtime. aspire.config.json declares language: "typescript/nodejs" and appHost.path: "apphost.mts". The AppHost runs on an isolated Node.js runtime inside aspire/ (with its own package.json and .aspire/ SDK modules) precisely so that the Node dependency graph never leaks into the Deno workspace at the project root. You author NetScript in Deno; Aspire orchestrates it from a sealed-off Node corner.
  2. The graph is derived, not hand-written. You do not edit apphost.mts to add a service. You declare infrastructure, services, plugins, and processors in appsettings.json, and each plugin contributes its own resources programmatically. createNetScriptAppHost reads the config, runs the contributions, and registers each resulting resource. The next section is the important one: it explains how that derivation works.

How the graph is generated from your plugins

This is the part that makes Aspire feel like part of the framework rather than a bolted-on tool: you never enumerate processes by hand. A NetScript plugin can declare an Aspire contribution, and the AppHost asks every installed plugin to contribute its own slice of the graph. The contract lives in @netscript/aspire and is deliberately small.

A plugin's contribution extends AspireNSPluginContribution. It names itself and, given a builder and a context, returns the resources it wants in the graph:

// plugins/<name>/src/aspire-contribution.ts (conceptual shape)
import { AspireNSPluginContribution } from "@netscript/aspire/public";
import type { AspireBuilder, AspireResource, ContributionContext } from "@netscript/aspire/application";

class WorkersContribution extends AspireNSPluginContribution {
  readonly pluginName = "@netscript/plugin-workers";

  // Push this plugin's API + background processor into the builder.
  contribute(builder: AspireBuilder, ctx: ContributionContext): readonly AspireResource[] {
    // ... register a deno-service (the API) and a deno-background (the processor)
    return [/* AspireResource[] */];
  }

  // Optional: extra env this contribution needs, and health checks the
  // plugin doctor will probe.
  declareEnv(ctx: ContributionContext) { return {}; }
  declareHealthChecks(ctx: ContributionContext) { return []; }
}

The host side composes those contributions. composeAppHost walks the plugin manifests, finds the ones that declare an aspire contribution, instantiates each, and collects the resources:

// How the AppHost composes plugin contributions into a builder
import { AspireTypeScriptBuilder } from "@netscript/aspire/adapters";
import {
  composeAppHost,
  type AspireBuilder,
  type AspireResource,
  type ComposePluginManifest,
  type ContributionContext,
} from "@netscript/aspire/application";
import { AspireNSPluginContribution } from "@netscript/aspire/public";

class WorkersContribution extends AspireNSPluginContribution {
  readonly pluginName = "@netscript/plugin-workers";

  contribute(_builder: AspireBuilder, _ctx: ContributionContext): readonly AspireResource[] {
    return [{ name: "workers-service", kind: "deno-service", port: 8080 }];
  }
}

const builder = new AspireTypeScriptBuilder();
const context: ContributionContext = {
  projectRoot: Deno.cwd(),
  port: (_key, fallback = 8080) => fallback,
  env: (source) => (typeof source === "string" ? source : source.kind === "literal" ? source.value : "configured"),
  resource: () => undefined,
  manifest: { name: "apphost" },
};
const plugins: readonly ComposePluginManifest[] = [
  {
    name: "@netscript/plugin-workers",
    contributions: { aspire: WorkersContribution },
  },
];

const { resources, registry } = composeAppHost({
  builder,
  context,
  plugins,
});
// `registry` is a ContributionRegistry keyed by pluginName; a duplicate
// plugin name throws DuplicateContributionError — the graph is dedup-checked.

Use @netscript/aspire/application when authoring focused composition modules. When an application or plugin prefers a single entrypoint for all configuration, composition, type, and testing exports, @netscript/aspire/public is the supported production aggregate export (import { composeAppHost } from "@netscript/aspire/public"). Note that the root export @netscript/aspire is reserved strictly for diagnostics (inspectAspire).

Editor schema validation workflow

To enable editor validation and autocompletion for appsettings.json, @netscript/aspire/schema (also re-exported by @netscript/aspire/public) provides generateAppSettingsJsonSchema:

import { generateAppSettingsJsonSchema } from "@netscript/aspire/schema";

// Generates a draft-7 JSON Schema derived directly from Zod AppSettingsSchema
const jsonSchema = generateAppSettingsJsonSchema();
await Deno.writeTextFile("./.vscode/appsettings.schema.json", JSON.stringify(jsonSchema, null, 2));

The shape of each node the contributions produce is deliberately narrow — every resource is one of a small, closed set of kinds:

AspireResource — what a plugin contribution returns
NameTypeDescription
name string Resource name in the AppHost graph (the label you see in the dashboard).
kind AspireResourceKind One of: deno-service, deno-background, container, database, cache. The closed set keeps the graph reasoned-about.
port number? TCP port the resource exposes, when applicable (services and plugin APIs; containers and processors may omit it).
metadata Record? Adapter-specific extras the builder backend understands.

So the AppHost is generated, but not in a "code-spitting templates" sense. It is generated in the stronger sense that the graph is assembled at boot from the plugins you installed. Add the sagas plugin and its sagas-api plus its supervisor processor appear; remove it and they vanish — no edit to apphost.mts required. The scaffold writes a thin register-*.mts helper layer (register-infrastructure.mts, register-services.mts, register-plugins.mts, register-background.mts, register-apps.mts, register-tools.mts) so each resource class lands through the same builder.addExecutable(...) path with permissions, working directory, HTTP endpoint, and OTEL environment resolved from config — but the content of the graph comes from your plugin set.

Service discovery: how resources find each other

Declaring resources is half the job; the other half is wiring them. A plugin API may need a sibling plugin's HTTP endpoint; a service may depend on another service; a processor may need the database and the cache. NetScript expresses those needs as references on the config entry, and Aspire resolves them into injected environment variables so each process starts with its neighbours' addresses already present.

The reference vocabulary is small and explicit. @netscript/aspire extracts three kinds of dependency from each entry:

Reference fields on a resource entry (resolved at compose time)
NameTypeDescription
ServiceReferences string[] Other services this resource calls. extractServiceReferences deduplicates repeated entries.
PluginReferences string[] Plugin APIs this resource calls. extractPluginReferences returns them for endpoint wiring (e.g. workers-api → sagas-api).
RequiresDb boolean Whether the resource needs the Postgres connection. extractDependencies normalizes it (default false).
RequiresKv boolean Whether the resource needs the Redis/KV connection. Normalized the same way (default false).

Because endpoints only exist once resources are created, the wiring happens in two passes: first every resource is created, then a second pass resolves each reference against the now-known graph and injects it. In the generated builder that resolution lands as the equivalent of getEndpoint('http') plus withEnvironment(...) for cross-references, and the database/cache connection strings for the RequiresDb/RequiresKv flags. By the time a process's entry point runs, the URLs it needs are in Deno.env — it never has to "discover" anything at runtime, which is exactly why there is no service-registry client in your handler code.

                          aspire start  (from aspire/)
                                 │
                                 ▼
              ┌──────────────────────────────────────────┐
              │  createNetScriptAppHost(appsettings.json) │
              │  + composeAppHost(plugin contributions)   │
              └──────────────────────────────────────────┘
                                 │  pass 1: create every resource
   ┌─────────────────────────────┼─────────────────────────────────────┐
   ▼                             ▼                                       ▼
(1) dashboard OTLP        (2) infrastructure                   (4) services
    :18888 + :4318            ├─ postgres  (Container)           └─ users (service)
                              └─ redis     (Container, cache)
                                 │
                                 ▼  pass 2: resolve references → inject env
    ┌──────────────────────────────────────────────────────────────────────────────┐
    │ (5) plugin APIs  workers-api        sagas-api        triggers-api               │
    │     auth-api         streams (port)                                             │
    │ (6) background processors: workers, sagas (bin/combined.ts);                    │
    │     triggers (src/runtime/trigger-processor.ts)                                 │
    │ (7) apps:  dashboard (Fresh)        (8) tools                                   │
    └──────────────────────────────────────────────────────────────────────────────┘

The order is not arbitrary. Infrastructure comes up first because everything else depends on it; references are resolved only after the resources they point at exist. The full port map for every runtime resource is consolidated under the Aspire reference — treat that as canonical and this essay as the orientation.

The resource graph a single aspire start brings up
NameTypeDescription
aspire (dashboard) https://localhost:18888 The Aspire dashboard. aspire start prints a login token. Live resource list, logs, structured traces, and the OTLP collector (:4318) surface here.
postgres Container Provisioned via Docker. The recommended engine is Postgres (swap to mysql / mssql — also Containers — or file-backed sqlite, which has no container, via --db), persistent (DataPath .data/postgres). The database that netscript db commands target — reachable only once Aspire is up.
redis Container (cache) Redis cache — the default --cache-backend; Redis-compatible. Backs KV/queue workloads for the runtime plugins. Swap to garnet (also a Container) or app-level deno-kv via --cache-backend.
users assigned port Example oRPC service (defineService). Routes /api/v1/users/* and the RPC surface at /api/rpc/*.
workers-api assigned port Workers plugin API. /api/v1/workers/{jobs,executions,tasks,seed}; trigger via POST /api/v1/workers/jobs/{id}/trigger.
sagas-api assigned port Sagas plugin API. /api/v1/sagas/{sagas,instances,publish} plus liveness at /health/live.
triggers-api assigned port Triggers plugin API (raw Hono, not oRPC). POST /api/v1/webhooks/inbound/generic, GET /api/v1/events.
auth-api assigned port Auth plugin oRPC service. /api/v1/auth/{signin,callback,signout,session,me} with one active backend (NETSCRIPT_AUTH_BACKEND).
streams assigned port Durable-streams producer runtime. Served as its own Aspire Deno service; workers/auth/sagas mirror execution state into it.
workers / sagas / triggers background processors Separate from the APIs: workers and sagas run from bin/combined.ts; triggers from src/runtime/trigger-processor.ts. Declared under appsettings BackgroundProcessors.

Each of these capabilities has its own hub: Services ,

Background jobs , and

Database are the practical pages behind the graph nodes above.

Customizing the generated AppHost: restart, regenerate, or hand-edit

Once the graph is running, the recurring question is which change needs which action. Because the register-*.mts helper layer is generated from netscript.config.ts/appsettings.json, most edits are a config change plus a regenerate, not a hand-edit of the AppHost. The regenerate command is netscript service generate — it rewrites only the Aspire helper files, never your service code.

Which change needs which action
NameTypeDescription
Handler / service / app code hot or restart resource Apps and background processors scaffold with Deno watch mode, so they reload on save. A plain service restarts from the dashboard. No regenerate, no AppHost restart.
Env / Permissions / Port / Workdir on a resource generate + restart AppHost Edit the resource's section in netscript.config.ts, run netscript service generate to rewrite the register-*.mts layer, then restart aspire start.
A reference: ServiceReferences / pluginReferences / dependsOn generate + restart AppHost Same flow. Pass 2 only re-resolves and re-injects services____http__0 on the next aspire start — a declared-but-not-generated reference does nothing.
Add / remove a plugin, or a new appsettings resource generate + restart AppHost netscript plugin install/remove (or a hand-added resource) changes the graph; the resource only joins on the next generate + aspire start.
An injected env value Aspire owns (OTEL_*, a connection string) restart the resource Environment is read once at process start, so bounce the affected resource for the new value to take effect.

Native database drivers need launch permissions

A resource's permission flags are set at launch, and they are separate from the per-task permissions you tighten inside a worker (those are covered in

Restrict worker task permissions ). This matters when a plugin loads a native (FFI) database driver — for example the native libSQL/Turso client the workers runtime can use for per-channel storage. Without FFI access the process crashes at import with NotCapable: Requires ffi access. The workers background processor therefore launches with an explicit permission set that includes --allow-ffi (and --allow-sys):

// plugins/workers/src/aspire/workers-contribution.ts — the background launch set
const WORKERS_BACKGROUND_PERMISSIONS = [
  '--unstable-kv',
  '--allow-net',
  '--allow-env',
  '--allow-read',
  '--allow-write',
  '--allow-run',
  '--allow-sys',
  '--allow-ffi', // native FFI drivers (e.g. libSQL/Turso) load here
] as const;

If you author a plugin or service that pulls in a native driver, give its resource the matching --allow-ffi (and any --allow-sys) flag through its contribution's launch permissions — a missing flag surfaces only at runtime as an FFI capability crash, not at generate time.

The dashboard: the local observability surface

When aspire start finishes booting, it prints a URL and a one-time login token for the dashboard at https://localhost:18888. The dashboard is the single pane of glass over the running graph:

  • Resources — every container and executable above, with status, endpoints, and environment.
  • Console logs — stdout/stderr per resource, so a failing background processor is one click away rather than buried in a terminal.
  • Structured logs and traces — aspire starts an OTLP collector at http://localhost:4318, and the spans and structured logs your handlers emit land here, correlated by traceparent, so a request that fans out across services is a single trace rather than scattered log lines.

This is where the orchestration story and the observability

story meet. Aspire already knows the topology — every resource and its OTEL environment — so it can stitch telemetry into that topology for free. Concretely, each resource is started with its OTEL_SERVICE_NAME and an OTEL_EXPORTER_OTLP_ENDPOINT pointed at the dashboard collector (http://localhost:4318, http/protobuf), so job dispatch, job execution, scheduler runs, and subprocess task continuation all emit real OpenTelemetry spans that surface here with no extra wiring — the trace context propagates into worker subprocesses over W3C traceparent.

Inside a handler, the scaffold's createJobTools(ctx) helpers add events, progress, and child spans to the active job trace. See

Observability for the full framework-and-handler trace path.

HTTP/2 and TLS: opt-in today

NetScript service listeners serve plaintext HTTP/1.1 by default, and that default is unchanged. HTTP/2 is available but strictly opt-in via TLS: Deno.serve negotiates h2 over ALPN the moment the listener has a certificate and key, so the way to get HTTP/2 is to hand the service TLS material. There are two ways to do that, and both flip the listener banner from http to https:

// Pass cert/key PEM material as ServeOptions.tls. Deno then negotiates HTTP/2
// over ALPN for clients that support it (HTTP/1.1 stays available as fallback).
import { createService } from '@netscript/service';
import type { ServiceTlsOptions } from '@netscript/service';

const tls: ServiceTlsOptions = {
  cert: await Deno.readTextFile('cert.pem'), // PEM certificate chain
  key: await Deno.readTextFile('key.pem'),   // PEM private key
};

await createService(router, { name: 'users' })
  .withHealth()
  .serve({ port: 3001, tls }); // note: your scaffold's port will differ
# When ServeOptions.tls is absent, the listener falls back to this env pair.
# BOTH must be set — one alone is ignored and the service stays plaintext.
NETSCRIPT_TLS_CERT_FILE=cert.pem \
NETSCRIPT_TLS_KEY_FILE=key.pem \
  deno run --allow-net --allow-read --allow-env services/users/main.ts

# With both present the service serves HTTPS + HTTP/2 — confirmed with `curl --http2`.

The --no-aspire escape hatch

Aspire is the default and the recommended local path, but it is not mandatory. The init command takes a --no-aspire flag (netscript init my-app --no-aspire) that skips scaffolding the orchestration layer entirely: no aspire/ folder, no AppHost to provision infrastructure, no dashboard. You start the generated Deno processes directly and provide your own infrastructure connection strings.

netscript init my-app --db postgres --service --service-name users --yes

# Step 2: orchestration brings up Postgres + Redis + every process.
cd aspire && aspire restore   # once
aspire start                    # dashboard at https://localhost:18888

# Step 3: database commands now work (provisioned through Aspire).
netscript db init --name init
# Scaffold WITHOUT the orchestration layer. No aspire/ folder is created.
netscript init my-app --db postgres --no-aspire --yes

# There is no `aspire start`. Start processes yourself:
deno task --cwd apps/dashboard dev

# You now own infrastructure: bring your own Postgres + cache and supply
# connection strings to each process. `netscript db` has no AppHost to
# provision through, so manage migrations against your own database URL.

When opting out is the right call:

  • A deploy target that does its own orchestration. Kubernetes, Nomad, a managed PaaS, or a Docker Compose file you already maintain — the platform owns process lifecycle and service discovery, so a second orchestrator on top is redundant. This is the primary production case.
  • A constrained or air-gapped environment. No Docker daemon, or a policy against running the Node AppHost runtime. You run Deno processes directly against externally-managed infrastructure.
  • A single-purpose slice. You only want the Fresh app, or one service, with no database or cache — the full graph would be overhead.

For local development of a full multi-plugin app, opting out is almost always the wrong trade: you would be hand-rebuilding exactly the wiring the contributions generate for free.

What Aspire does and does not cover for production

Choosing Aspire as the default is an opinion, and this essay states its boundaries.

Aspire is the local-development orchestration story. Its job is to make git cloneaspire start produce a complete, observable, correctly-wired stack on one machine. It excels at that. What it deliberately does not try to be:

  • A production deployment system. The AppHost provisions Postgres and Redis as local Docker containers for dev convenience. It is not your production database, not your production cache, and not a cluster scheduler. In production you point processes at managed/clustered infrastructure and let your platform own lifecycle and scaling.
  • A replacement for your orchestrator of record. The same resource model that is a gift on a laptop is redundant under Kubernetes or a PaaS that already does discovery, health, and restarts. That is precisely why --no-aspire is a first-class exit, not an afterthought.

The remaining trade-offs of the default path:

  • A second runtime in the tree. The AppHost is Node/TypeScript while your app is Deno — a deliberate isolation so the two dependency graphs never contaminate each other, at the cost of "what runtime is this?" having two answers in one repo.
  • Docker is a hard dependency of the happy path. No Docker daemon means the default workflow does not start — which is exactly when --no-aspire plus your own infrastructure earns its place.
  • The wiring is implicit. The two-pass reference resolution is invisible at authoring time, so inspect generated environment variables and the resource graph when debugging service discovery.

Glossary

  • AppHost — the program that defines and runs an Aspire resource graph. In NetScript it is the generated TypeScript aspire/apphost.mts, configured by aspire.config.json.
  • Contribution — a plugin's declaration of the resources it adds to the graph, expressed by extending AspireNSPluginContribution and returning AspireResource[] from contribute(...).
  • Resource — any node Aspire manages: a container (Postgres, Redis), a database, a cache, a deno-service, or a deno-background processor.
  • OTLP — the OpenTelemetry protocol endpoint (http://localhost:4318) aspire starts so the dashboard can collect the spans and structured logs your handlers emit.

Where to go next

Services , and

Background jobs are the hubs behind the graph nodes.