# Run approved prompts from Node and TypeScript

Install the Agent SDK, start a resident runtime, render a prompt, wrap your OpenAI or Anthropic client so every call is measured and carries the release's inference settings, and expose health.

`@airprompter/agent-sdk` is the one-install facade for Node 20 or later (ESM and CommonJS, no runtime dependency beyond `node:crypto`). It pulls signed releases, keeps them in an encrypted restart-safe store, renders with trust-aware variables, and writes content-free counters to a local spool that it uploads itself. This page builds the resident shape the way the reference application does.

## Before you start

You need:

- Node 20 or later;
- the agent’s organization id, agent id, and target environment;
- an Agent key for that environment in `AIRPROMPTER_AGENT_KEY`;
- the AirPrompter root public key for the deployment you talk to, saved as a JSON Web Key file in your project (the public service’s deployment is `prod`); and
- your provider client (`openai`, `@anthropic-ai/sdk`, or the Vercel AI SDK `ai`) with its own credential.

Install the facade:

```sh
npm install @airprompter/agent-sdk
```

The facade exact-pins `@airprompter/agent-core`, `-sync`, `-runtime`, and `-telemetry` at the same version (0.2.13 today, protocol 0.3.4). The provider packages are optional peers: nothing in the SDK imports them.

## Start a resident runtime

Read configuration from the environment and keep the key out of every log line. The reference application’s `config.mjs` loads a `.env` file and refuses to start without the key, without ever printing it.

```ts
import { readFileSync } from "node:fs";
import { AirPrompterAgent } from "@airprompter/agent-sdk";

const ap = await AirPrompterAgent.start({
  organizationId: process.env.AIRPROMPTER_ORG_ID!,
  agentId: process.env.AIRPROMPTER_AGENT_ID!,
  target: "prod",                                   // this app's environment: dev | staging | prod
  apiKey: process.env.AIRPROMPTER_AGENT_KEY,        // omit to run fully offline from the store
  stateDir: "/var/lib/acme-support-bot",            // the encrypted store and the spool live here
  root: {
    pinned: JSON.parse(readFileSync("./airprompter-root.jwk.json", "utf8")),
    hostedEnvironment: "prod",                      // which AirPrompter deployment signed it; never your target
  },
  sync: { mode: "resident", pollSeconds: 30 },
  models: ["claude-sonnet-5", "claude-haiku-4-5"],  // what this process can call, as the provider names them
  heartbeatSeconds: 300,
  logger: (event) => {
    if (event.event === "heartbeat") return;        // the routine one; keep the rest
    console.log(JSON.stringify(event));            // events carry ids, reasons, and counts, never prompt text
  },
});
```

What `start()` does, in order: reads the store and verifies the active slot before any network call; falls back to the other slot, then to a vendored bundle if you passed one; and only with nothing verified runs one synchronous sync. If that fails too it throws `AgentStartError` with `code: "no_verified_release"` and a message naming the cause. Serving an unverified release is never an option.

`models` matters: a release that pins a slot to a model no instance declares is refused when it is sealed, so declare what you can really call.

**Expected result:** `ap.status().generation` is the promoted generation and `ap.status().applyState` is `active`. Print `ap.status()` at start the way the reference app does; it contains ids, timestamps, and counts only.

## Render a prompt and call your model

1. Wrap your provider client once at start with `ap.wrap()`.
2. Render the prompt by its key in code with the variables it declares; pass `subject` when a rollout should be sticky per customer.
3. Send `rendered.text` as the system prompt to `rendered.model` through the wrapped client.
4. Store `rendered.runRef` beside your own record so you can file feedback later.

```ts
import OpenAI from "openai";

const openai = ap.wrap(new OpenAI());               // once, at start: the same client, every call observed

const rendered = ap.prompt("support.triage", { subject: customerId }).render({
  team: "Billing",
  ticket: ticketText,                                // declared end_user: fenced as <ticket>…</ticket> for the model
});

const completion = await openai.chat.completions.create({
  model: rendered.model,                             // the model the release pinned
  messages: [
    { role: "system", content: rendered.text },
    { role: "user", content: ticketText },
  ],
});

await saveTicketNote({ runRef: rendered.runRef, versionId: rendered.versionId, arm: rendered.arm });
```

`render()` never touches the network. It returns `text`, `model`, `versionId`, `arm`, `generation`, `tag`, an optional `inference` block, and `runRef`, a content-free receipt you keep beside your own record. A missing required variable throws; a value for a variable the prompt did not declare throws; a variable declared as end-user input is fenced so the model can see where untrusted text starts and stops. `ap.prompt(tag).variables()` lists what the slot declares.

`subject` is optional. During an A/B test it makes arm assignment sticky per customer; the id is hashed on your host and never sent.

### How the wrapped call is attributed

`ap.wrap(client)` returns a proxy over the same `openai` or `@anthropic-ai/sdk` client; no internals are patched and no provider package is imported. A call is matched to a render in this order: an enclosing `ap.attribute(rendered, () => ...)` scope; else a request text (`system`, `instructions`, or a message) that equals one of the last 256 renders, matched by hash; else the call passes through unattributed and is logged `wrap_unattributed`. Nothing the wrapper does can fail your call.

Use `ap.attribute` when the rendered text is not in the request verbatim, for example when you split it into parts:

```ts
const reply = await ap.attribute(rendered, () =>
  anthropic.messages.create({
    model: rendered.model,
    max_tokens: 400,
    system: rendered.text,
    messages: [{ role: "user", content: ticketText }],
  }),
);
```

If you would rather not wrap, `ap.observe(rendered, () => call())` times one call, reads `usage` off the response (OpenAI, Anthropic, and Bedrock shapes), classifies a failure, and returns the result unchanged. `ap.report({...})` files an observation by hand, and `ap.checks(rendered, output)` runs the slot’s declared output checks on text you already have.

## Variables from your system

A prompt author adds `{{customer_tier}}` to a version; your app knows a customer’s tier. Instead of changing every call site, register once how the value is found, and every version that uses the variable is filled at render time — a version that does not use it never causes the lookup:

```ts
const ap = await AirPrompterAgent.start({
  ...,
  variables: {
    brand: "Acme",                                                          // a literal: always this
    customer_tier: {                                                        // a source: your system, asked at render
      resolve: async ({ subject }) => crm.tierOf(subject),                  // undefined = "I have none" (a required variable then fails as reason empty)
      trust: "operator",                                                    // required: whose text this is
      timeoutMs: 500,                                                       // default 2 s; 64 KiB byte bound
    },
  },
});
ap.variables.provide("region", process.env.REGION!);                        // later is fine
```

Render with `renderAsync()` when a source may be needed; `render()` stays synchronous and throws `VariableSourceRequiredError` naming the variables if a source would have to run. The value a render uses is decided in one order and stops at the first that answers: the call site’s value, then a registered source (consulted only for a declared variable that is required or present in the text), then the default the prompt’s settings declare for an optional variable, then nothing — a required variable is `MissingVariableError`.

Trust is the load-bearing rule: a source is text nobody in AirPrompter reviewed, so it names its trust, and the render uses the stricter of the prompt’s declaration and the source’s. An `end_user` source is fenced wherever the prompt declared `operator` (logged once as `variable_source_trust_stricter`); a source can never loosen a declaration. A source that throws, times out, oversizes, answers something other than text, or answers nothing for a required variable fails the render as `VariableSourceError { tag, variable, reason }` (`threw`, `timeout`, `too_large`, `not_text`, `empty`), logged by name and reason only, and counted on the board as `render_missing_variable`. A value is never logged or reported — only a variable’s name; in hosted mode the filled value is posted with the run like any other variable.

Find an uncoverable version before the first request: `ap.prompt(tag).needs(values)` names what the call site must still pass; `ap.status().variables` lists the sources you registered and, per prompt and rollout arm, the required names nothing fills. A workflow’s `flow.renderStepAsync(stepId, values)` fills a step the same way; `ManagedAgent.start({ variables })` fills required and runtime-sourced variables before a hosted run is posted.

On the console, Settings › Prompt settings › Variables is where a prompt’s declarations live: the name, whether it is required, who writes it (your code, or an end user — fenced), who is expected to fill it (your code where the prompt is rendered, or your app’s registered source), and a default for an optional variable your code writes (end-user text never has one). A version whose text uses a name the prompt does not declare cannot be staged from the update drawer; the drawer says which name. A runtime that calls home reports in its heartbeat which variables it can fill (SDK 0.2.13, serving a release sealed at protocol 0.3.4 or later), so the drawer warns when a variable your app is expected to fill is filled on fewer of the reporting instances than all of them, before the promotion rather than after.

## The release’s inference settings

An agent’s update declares how each prompt is called: temperature, top-p, an output cap, stop sequences, reasoning effort, set beside the model when the update is staged. The release seals those with the text (`rendered.inference`, integers on the wire: `temperatureMilli: 200` is 0.2, `topPBps: 9000` is 0.9). A wrapped call attributed to that render goes out with the release’s values as `temperature`, `top_p`, `max_completion_tokens` / `max_output_tokens` / `max_tokens`, `stop` / `stop_sequences`, and `reasoning_effort` / `reasoning.effort`.

The reference application’s `inference-proof.mjs` shows it with a stand-in Anthropic client: the call site writes `max_tokens: 100, temperature: 1`; what goes out carries the release’s temperature, cap, and stop sequence. Three rules govern it, each reported through your `logger`:

| Situation                                                                                                                                          | What happens                                                                               | Event                                            |
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| The call site wrote a different value                                                                                                              | The release’s value goes out; you are told once                                            | `wrap_inference_overridden` names the parameters |
| The call names another model than the release’s                                                                                                    | The call keeps its own parameters; settings sealed for one model are never sent to another | `wrap_inference_model_mismatch`                  |
| The call cannot carry a setting (a stop sequence on Responses, top-p beside temperature on Anthropic Messages, anything beside a `thinking` block) | The setting is reported, not sent                                                          | `wrap_inference_unsupported` with a reason       |

Instrumentation that calls a provider itself can use `applyInference(kind, params, inference, { model })`, exported from the facade, to get the same result.

## Use the Vercel AI SDK

The middleware applies attribution and inference settings through `transformParams` and taps streams for usage and finish reason:

```ts
import { generateText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";

const model = wrapLanguageModel({ model: openai(rendered.model), middleware: ap.aiSdkMiddleware() });
const { text } = await generateText({ model, system: rendered.text, prompt: ticketText });
```

Every provider the AI SDK drives is observed the same way. Pass `ap.aiSdkMiddleware({ version: "v2" })` to pin a middleware version if your AI SDK release needs one.

## Run a workflow

A workflow slot carries ordered steps. The SDK hands you the steps; your program executes them, running tools or other work in between:

```ts
const flow = ap.workflow("onboarding.flow", { subject: customerId });
let previous = "";
for (const step of flow.steps) {             // { stepId, ordinal, versionId, text, runRef, model, inference? }
  const reply = await ap.attribute(step, () =>
    openai.chat.completions.create({
      model: flow.model,
      messages: [{ role: "system", content: step.text }, { role: "user", content: previous || intake }],
    }),
  );
  previous = reply.choices[0].message.content ?? "";
}
```

Each step has its own run reference and, from protocol 0.3.2, its own inference settings, set per step when the update is staged, for the workflow’s pinned model.

## File feedback later

In day-to-day work the signal arrives long after the call: an agent accepts a draft, a customer rates a reply. Feedback rides on the run’s telemetry window as numbers, booleans, and declared enums only; free text is refused by schema.

```ts
ap.feedback(runRef, { thumbs: "up" });
ap.feedback(runRef, { accepted: true, rating: 4, timeToAcceptMs: 42_000 });
```

`feedback()` returns `false` when the run reference does not parse or no signal was accepted; a name outside the catalogue is dropped and logged `feedback_rejected`. Which signal decides “quality” for a prompt, and which are guardrails, is set per prompt in the console.

## Expose health

Every runtime answers the same health document the daemon does. Mount it on any HTTP server, as the reference `healthz.mjs` does:

```ts
import { createServer } from "node:http";

const healthz = ap.healthzHandler();               // 200 with a verified release active; 503 without
createServer((req, res) => (req.url === "/healthz" ? healthz(req, res) : res.writeHead(404).end())).listen(8787);

process.on("SIGTERM", async () => { await ap.stop(); process.exit(0); });
```

`ap.healthz()` returns the document itself: `ok`, `status` (`ok`, `degraded`, or `failing`), `reasons`, `generation`, `applyState`, `leaseExpiresAt`, `lastSyncOutcome`, and the spool depth. `degraded` covers a lapsed lease that still serves, three or more consecutive sync failures, an uploader backing off, and a spool at 80 percent of its budget.

## Read the log

`logger` receives one object per event with an `event` name and content-free fields. The ones worth alerting on:

| Event                     | Meaning                                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------- |
| `awaiting_first_unlock`   | A release is staged under unlock-required and nothing is active yet; `prompt()` refuses until an unlock |
| `heartbeat_refused`       | The server refused a heartbeat; the message and up to eight validation issues are attached              |
| `pointer_behind`          | The edge pointer lagged the server; the runtime went straight to the signed manifest                    |
| `vendored_bundle_refused` | A bundle you passed was older than what the host holds, or failed verification                          |
| `golden_set_failed`       | A staged release fell below a golden set’s pass floor and stays staged                                  |
| `wrap_unattributed`       | A wrapped call carried no known render text                                                             |
| `segment_dropped_by_sink` | A telemetry segment was refused by its destination and dropped, counted                                 |

## Apply policy, unlock, rollback

Under an environment policy of unlock-required, a new generation is staged, not activated. Three things unlock it, and only your side holds them: an operator (`airprompter unlock`), an update window (`apply.window: "02:00-04:00 Europe/Berlin sat,sun"`), or your change-control hook:

```ts
apply: {
  onStaged: async (staged) => {
    const ticket = await changeControl.open({ generation: staged.generation, note: staged.unlockRequest?.note });
    if (ticket.approved) staged.activate();       // resolve without calling activate() to leave it staged
  },
},
```

`await ap.unlock()` activates a staged release from code; `await ap.rollback()` flips to the other slot instantly and is stamped as a forced downgrade. The policy is pinned on the host on first use; a later release may tighten it and never loosen it.

## Serverless

Use `sync: { mode: "on_invoke" }` and wrap the handler in `ap.invoke(() => handler())`: the runtime syncs around the invocation and posts the invocation’s counters as one segment **before** it returns, so a frozen function loses nothing. `telemetry: { flush: "background" }` is the opt-out for platforms that keep running after the response.

## Troubleshooting

### `start()` throws `no_verified_release`

Read the message: a 404 means nothing is promoted to that environment; a 401 is the key; a 403 names a key bound to another agent or environment; a transport failure carries the base URL. With `apiKey` omitted the runtime is offline and needs a store or a vendored bundle.

### Calls are logged `wrap_unattributed`

The request did not carry a rendered text verbatim, or the render is older than the last 256. Wrap the call in `ap.attribute(rendered, ...)`.

### The release’s temperature did not reach the provider

Check the event: `wrap_inference_model_mismatch` means the call named another model; `wrap_inference_unsupported` names the parameter the call shape cannot carry.

### `healthz` answers 503 after a restart

The store had nothing verified, or a release is staged under unlock-required with nothing active. `ap.status().applyState` says which; `awaiting_unlock` needs an unlock from your side.

## Completion checklist

- `npm install @airprompter/agent-sdk` and `start()` succeeds against `dev` with a fresh state directory.
- The Agent key reaches the process only through `AIRPROMPTER_AGENT_KEY`.
- `ap.wrap()` observes your provider client and the Metrics tab fills a few minutes after upload.
- You keep `runRef` beside your own record and file feedback against it.
- `/healthz` is mounted and your orchestrator probes it.

## Next steps

Keep Agent keys off application hosts with [the fleet pattern](https://airprompter.com/docs/integrations/fleet), understand every row that leaves the host in [Telemetry](https://airprompter.com/docs/integrations/telemetry), or read the same integration in [Python](https://airprompter.com/docs/integrations/python).

---

Canonical: https://airprompter.com/docs/integrations/typescript
