# Discover services

**Goal:** call another plugin's (or another workspace member's) oRPC service from your app, end to end — declare the dependency so Aspire injects the callee's URL, then obtain a fully typed client from `@netscript/sdk` that resolves that URL at request time. No registry, no hardcoded `localhost:<port>`, no codegen.

alpha

There is **no service-registry API** in NetScript. "Discovery" is two cooperating mechanics: Aspire injects each referenced service's resolved endpoint as an environment variable (`services__<name>__http__0`), and the SDK's typed client reads that variable lazily via `@netscript/sdk/discovery` when it builds the request URL. Declare the reference, import the contract, construct the client — that is the whole recipe.

![The shared contract object feeds the generated typed client; the client resolves the callee's URL from an Aspire-injected services__ env var and issues a cache-first oRPC query that the same contract's service implements.](https://rickylabs.github.io/netscript/netscript/assets/diagrams/sdk-data-flow.svg)

*Discovery flow: shared contract → typed client → URL resolved from the Aspire services__ env var → oRPC call to the service that implements the same contract.*

## Before you start

**Prerequisites**

| Name | Type | Description |
| --- | --- | --- |
| `A NetScript workspace` | `netscript init` | An existing project with at least the caller (an app, plugin, or service) and the callee service already present. Run commands from the workspace root. |
| `The callee service` | `services//` | A target service that answers on its own port over /api/rpc/* — e.g. the example users service on :3001. See the Add a service recipe to create one. |
| `@netscript/sdk` | `import alias` | The SDK provides the typed client (createServiceClient / defineServices) and the discovery readers (@netscript/sdk/discovery). It is a workspace dependency of apps and consuming services. |
| `The shared contract` | `@/contracts` | Both the service and the caller import the SAME oRPC contract object through the project alias, so the client's input/output types are inferred — never duplicated. |
| `Aspire (for resolved URLs)` | `aspire start` | Aspire injects the services____http__ env vars that discovery reads. Without it you must set those vars yourself (see pitfalls). |

This recipe assumes the callee is the example `users` service (port `3001`) and the caller is an app or another service. Substitute your own service name throughout.

## Step 1 — Declare the service reference

The caller declares which services it depends on by name in its resource entry under `ServiceReferences`. The init scaffold already writes this into the Aspire `appsettings.json` when you scaffold an app against a service; to add a dependency to an existing resource, add the callee's name to that resource's `ServiceReferences` array.

```jsonc
// aspire/appsettings.json — the caller (an app here) depends on the users service
{
  "NetScript": {
    "Apps": {
      "web": {
        "Runtime": "deno",
        "Type": "app",
        "Port": 3000,
        "ServiceReferences": ["users"]
      }
    }
  }
}
```

`ServiceReferences` is the live field. The names you list here must match the service resource keys under `NetScript.Services`.

> What the reference actually wires
>
> At generate time the Aspire helper does a two-pass registration: pass 1 creates every service resource, pass 2 walks each resource's
>
> ServiceReferences
>
> and, for each referenced name, calls
>
> getEndpoint('http')
>
> on the target and injects it into the caller as
>
> services__<name>__http__0
>
> via
>
> withEnvironment(...)
>
> . That env var is exactly what the SDK's discovery layer reads at request time — so declaring the reference is what makes
>
> getServiceUrl('users')
>
> resolve.

## Step 2 — Regenerate the Aspire helpers

The `register-services` helper that performs the two-pass wiring is generated from `appsettings.json`. After editing `ServiceReferences`, regenerate so the new dependency is emitted into the AppHost helpers:

```bash
# from the workspace root — regenerates the Aspire helper files only
netscript service generate
```

`service generate` only rewrites the Aspire helper files (it does not touch your service code). The next `aspire start` will inject the `services__users__http__0` variable into the caller's environment.

## Step 3 — Construct the typed client

In the caller, import the **same contract object** the service implements and hand it to `createServiceClient`. The `serviceName` you pass is the discovery key — it must match the name you referenced in Step 1. The client is fully typed from the contract; there is no generated client file to import.

```ts
// web/src/clients/users.ts — a typed client for the discovered users service
import { createServiceClient } from '@netscript/sdk/client';
import { UsersContractV1 } from '@my-app/contracts';

// serviceName is the discovery key — it resolves services__users__http__0 at call time.
export const usersClient = createServiceClient({
  contract: UsersContractV1,
  serviceName: 'users',
});

// Fully inferred input/output — a renamed contract field is a compile error here.
const { items } = await usersClient.list({ limit: 20 });
```

The URL is resolved **lazily**, on the first call, by the internal HTTP link — so the client can be constructed at import time (even in a browser bundle) without touching Deno APIs. By default it targets `<resolved-url>/api/rpc/v1/<service>` over HTTP.

**CreateServiceClientOptions (createServiceClient) — confirmed surface**

| Name | Type | Description |
| --- | --- | --- |
| `contract` | `TContract (required)` | The shared oRPC contract object. Drives both client typing and HTTP method inference — import the same object the service implements. |
| `serviceName` | `string (required)` | The discovery key. Resolved as services____http__ on the server (and the VITE_ form in the browser). Must match the ServiceReferences entry. |
| `routerName` | `string?` | Overrides the URL path segment for the router; defaults to serviceName. Use when the mounted router path differs from the service name. |
| `protocol` | `'http' \| 'https'?` | Discovery protocol. Defaults to 'http' (resolves the __http__ endpoint). |
| `apiPath` | `string?` | Base RPC path the runtime mounts. Defaults to '/api/rpc' — match what the service serves. |
| `apiVersion` | `string?` | API version segment in the URL. Defaults to 'v1'. |
| `propagateTraceContext` | `boolean?` | Attach W3C traceparent/tracestate headers automatically from the active trace context. Defaults to true. |

## Step 4 — Call it (and handle typed errors)

The client methods mirror the contract's procedure tree. Wrap calls in the SDK's `safe()` helper to get a tuple instead of a throw, and narrow defined errors with `isDefinedError`:

```ts
// web/src/routes/users.ts — typed call with safe-style error handling
import { isDefinedError, safe } from '@netscript/sdk/client';
import { usersClient } from '../clients/users.ts';

const [error, result] = await safe(usersClient.list({ limit: 20 }));

if (error && isDefinedError(error)) {
  // error.code and error.data are typed from the contract
  console.error('users.list failed:', error.code, error.data);
} else {
  // result.items is the contract's output type
  return result.items;
}
```

## Many services at once — `defineServices`

When the caller talks to several services, `defineServices` builds the clients (plus cache-aware query factories and TanStack Query utils) from one map. The map key is the discovery key unless you override `serviceName`.

```ts
// web/src/clients/users.ts — one service, direct calls only
import { createServiceClient } from '@netscript/sdk/client';
import { UsersContractV1 } from '@my-app/contracts';

export const usersClient = createServiceClient({
  contract: UsersContractV1,
  serviceName: 'users',
});

const page = await usersClient.list({ limit: 20 });
void page;
```

```ts
// web/src/sdk.ts — clients + query factories + query utils for several services
import { defineServices } from '@netscript/sdk';
import { OrdersContractV1, UsersContractV1 } from '@my-app/contracts';

export const sdk = defineServices({
  // map key is the discovery key (services__users__http__0, services__orders__http__0)
  users: { contract: UsersContractV1 },
  orders: { contract: OrdersContractV1, options: { staleTime: 60_000 } },
});

const people = await sdk.clients.users.list({ limit: 20 });
const orderPage = await sdk.queries.orders.list({ limit: 20, offset: 0 });
void people;
void orderPage;
```

`defineServices` returns `{ clients, queries, queryUtils }`. The `serviceName` per entry defaults to the map key, so `users` resolves `services__users__http__0` automatically — override it only when the registered service name differs from your local map key.

## Manual / non-Aspire resolution

The discovery readers are usable on their own when you are not running under Aspire — for a smoke test, a one-off script, or a custom transport. `getServiceUrl(name)` throws a clear error naming the missing env var; `isServiceAvailable(name)` is the non-throwing probe; `getAllServices()` lists every `services__*` name in the environment.

```ts
// scripts/check-users.ts — resolve a discovered URL by hand
import { getAllServices, getServiceUrl, isServiceAvailable } from '@netscript/sdk/discovery';

if (isServiceAvailable('users')) {
  const baseUrl = getServiceUrl('users', 'http'); // reads services__users__http__0
  console.log('users at', baseUrl, '| all:', getAllServices());
}
```

The server-side lookup key is `services__<name>__http__<index>`; the browser path checks the `VITE_`-prefixed forms first (Aspire injects those for Vite apps). Set the env var yourself to point a client at a fixed URL with no orchestrator:

```bash
# point the users client at a locally-running service, no Aspire
services__users__http__0=http://localhost:3001 deno task --cwd web dev
```

## End to end in one pass

The four steps above, run start to finish against the example `users` service. Copy the file, then run the command sequence from the workspace root:

```ts
// web/src/clients/users.ts — the resulting file (contract in, typed client out)
import { createServiceClient, isDefinedError, safe } from '@netscript/sdk/client';
import { UsersContractV1 } from '@my-app/contracts';

// serviceName is the discovery key — resolves services__users__http__0 at call time.
export const usersClient = createServiceClient({
  contract: UsersContractV1,
  serviceName: 'users',
});

// A typed call with safe-style error handling — no hardcoded URL anywhere.
export async function firstUsers() {
  const [error, result] = await safe(usersClient.list({ limit: 20 }));
  if (error && isDefinedError(error)) {
    throw new Error(`users.list failed: ${error.code}`);
  }
  return result.items;
}
```

```bash
# From the workspace root, in order:

# 1. Declare the dependency — add "users" to the caller's ServiceReferences
#    array in aspire/appsettings.json (see Step 1 above).

# 2. Regenerate the Aspire helpers so the reference is wired into the AppHost.
netscript service generate

# 3. Bring the graph up — Aspire injects services__users__http__0 into the caller.
cd aspire && aspire start && cd ..

# 4. Run the caller; the typed client resolves the URL lazily on the first call.
deno task --cwd web dev
```

With `aspire start` up, `usersClient.list({ limit: 20 })` resolves `services__users__http__0`, issues the oRPC call to `<resolved-url>/api/rpc/v1/users`, and returns the contract's typed `items`. No registry, no `localhost:<port>`, no generated client file.

## Production pitfalls

> Footguns before you ship
>
> - **Alpha surface.** NetScript packages share the aligned `0.0.1-beta.11` version; the discovery env-var convention and client option names are stable in the scaffold but may shift before `1.0`. Pin the version you build on.
> - **The reference must be declared, then generated.** Adding a `ServiceReferences` entry does nothing until you re-run `netscript service
>   generate` and restart `aspire start`. A missing reference means `getServiceUrl` throws *"Service URL not found … Expected environment variable: services__<name>__http__0"*.
> - **The discovery key must match.** `serviceName` (and the `defineServices` map key) must equal the service's registered name, or discovery resolves a variable that was never injected.
> - **The served path is `/api/rpc/*`.** The client defaults to `apiPath: '/api/rpc'` and `apiVersion: 'v1'`; if your service mounts a different path, set them explicitly or calls 404. It is *not* a bare `/rpc`.
> - **Aspire injects the URL; outside Aspire you must.** Without an orchestrator the `services__<name>__http__0` variable is unset — provide it yourself (env var) or the client cannot find the service.

## See also

[Do — Add a service  Create the callee first: contract → defineService → an oRPC service answering on its own port.](https://rickylabs.github.io/netscript/netscript/services-sdk/how-to/add-a-service/)

[Capability — Services & contracts  Why the shared contract object is the single source of truth, and how a typed client cannot drift from the service.](https://rickylabs.github.io/netscript/netscript/capabilities/services/)

[Look up — @netscript/sdk  The full SDK surface: createServiceClient, defineServices, the discovery readers, query factories, and TanStack utils.](https://rickylabs.github.io/netscript/netscript/reference/sdk/)

[Understand — Orchestration with Aspire  How Aspire wires resources, resolves endpoints, and injects the services__ env vars discovery reads.](https://rickylabs.github.io/netscript/netscript/explanation/aspire/)

For the concepts behind the typed-client surface see [Typed SDK & client](https://rickylabs.github.io/netscript/netscript/services-sdk/sdk/) ; the sibling recipe that builds the callee is [Add a service](https://rickylabs.github.io/netscript/netscript/services-sdk/how-to/add-a-service/) ; and the orchestration that injects the URLs is explained in [Orchestration with Aspire](https://rickylabs.github.io/netscript/netscript/explanation/aspire/) .

[How-to guides](https://rickylabs.github.io/netscript/netscript/how-to/) [Expose OpenAPI & Scalar](https://rickylabs.github.io/netscript/netscript/services-sdk/how-to/expose-openapi-scalar/)
