AirPrompter documentation
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.
Available
@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 SDKai) with its own credential.
Install the facade:
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.
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
- Wrap your provider client once at start with
ap.wrap(). - Render the prompt by its key in code with the variables it declares; pass
subjectwhen a rollout should be sticky per customer. - Send
rendered.textas the system prompt torendered.modelthrough the wrapped client. - Store
rendered.runRefbeside your own record so you can file feedback later.
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:
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:
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:
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:
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.
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:
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:
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-sdkandstart()succeeds againstdevwith 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
runRefbeside your own record and file feedback against it. /healthzis mounted and your orchestrator probes it.
Next steps
Keep Agent keys off application hosts with the fleet pattern, understand every row that leaves the host in Telemetry, or read the same integration in Python.