@stigg/agent-sdk-js 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1147 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +117 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +258 -0
  8. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  10. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  11. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  12. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
  13. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
  14. package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
  15. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  16. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +30 -0
  17. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +79 -0
  18. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  19. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  20. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
  21. package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
  22. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
  23. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
  24. package/dist/bindings/proxy/headers.d.ts +6 -0
  25. package/dist/bindings/proxy/headers.js +12 -0
  26. package/dist/bindings/proxy/index.d.ts +21 -0
  27. package/dist/bindings/proxy/index.js +18 -0
  28. package/dist/bindings/proxy/types.d.ts +8 -0
  29. package/dist/bindings/proxy/types.js +2 -0
  30. package/dist/bindings/shared/estimate.d.ts +27 -0
  31. package/dist/bindings/shared/estimate.js +38 -0
  32. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  33. package/dist/bindings/shared/meter-stream.js +151 -0
  34. package/dist/bindings/shared/token-usage.d.ts +148 -0
  35. package/dist/bindings/shared/token-usage.js +414 -0
  36. package/dist/bindings/shared/types.d.ts +79 -0
  37. package/dist/bindings/shared/types.js +2 -0
  38. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  39. package/dist/bindings/shared/weighted-metrics.js +44 -0
  40. package/dist/config.d.ts +136 -0
  41. package/dist/config.js +97 -0
  42. package/dist/core/context.d.ts +151 -0
  43. package/dist/core/context.js +154 -0
  44. package/dist/core/dimensions.d.ts +22 -0
  45. package/dist/core/dimensions.js +47 -0
  46. package/dist/core/hooks.d.ts +21 -0
  47. package/dist/core/hooks.js +47 -0
  48. package/dist/core/session.d.ts +161 -0
  49. package/dist/core/session.js +403 -0
  50. package/dist/decision-text.d.ts +15 -0
  51. package/dist/decision-text.js +54 -0
  52. package/dist/engine/composite-engine.d.ts +72 -0
  53. package/dist/engine/composite-engine.js +135 -0
  54. package/dist/engine/create-engine.d.ts +16 -0
  55. package/dist/engine/create-engine.js +93 -0
  56. package/dist/engine/decision.d.ts +37 -0
  57. package/dist/engine/decision.js +128 -0
  58. package/dist/engine/engine.d.ts +166 -0
  59. package/dist/engine/engine.js +11 -0
  60. package/dist/engine/node-sdk-engine.d.ts +125 -0
  61. package/dist/engine/node-sdk-engine.js +262 -0
  62. package/dist/engine/report.d.ts +106 -0
  63. package/dist/engine/report.js +185 -0
  64. package/dist/engine/rest-governance-engine.d.ts +47 -0
  65. package/dist/engine/rest-governance-engine.js +220 -0
  66. package/dist/errors.d.ts +78 -0
  67. package/dist/errors.js +151 -0
  68. package/dist/headers.d.ts +27 -0
  69. package/dist/headers.js +63 -0
  70. package/dist/index.d.ts +48 -0
  71. package/dist/index.js +85 -0
  72. package/dist/internal.d.ts +33 -0
  73. package/dist/internal.js +81 -0
  74. package/dist/logger.d.ts +15 -0
  75. package/dist/logger.js +20 -0
  76. package/dist/sdk-identity.d.ts +36 -0
  77. package/dist/sdk-identity.js +43 -0
  78. package/dist/stigg-agent-sdk.d.ts +242 -0
  79. package/dist/stigg-agent-sdk.js +594 -0
  80. package/dist/types.d.ts +531 -0
  81. package/dist/types.js +97 -0
  82. package/package.json +80 -3
package/README.md CHANGED
@@ -1,3 +1,1148 @@
1
- # Temporary Holding Version
1
+ # `@stigg/agent-sdk-js` — agent governance for the agent stack (TypeScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A thin SDK that gates and meters agent runs against [Stigg](https://stigg.io), plus runnable
4
+ examples for the two layers it binds to: a client you wrap, and a gateway you call or operate.
5
+ The examples are not part of the published package - they live beside the source in the Stigg
6
+ SDK monorepo, and the paths below are relative to it.
7
+
8
+ ```bash
9
+ npm install @stigg/agent-sdk-js # or: yarn add @stigg/agent-sdk-js
10
+ ```
11
+
12
+ Requires Node 22 or later. Ships CommonJS with type declarations, and a `./internal` subpath for
13
+ the engine seam.
14
+
15
+ Everything here runs **with no API keys and no Stigg account**: a built-in mock stands in for
16
+ both the LLM vendors and the Stigg API.
17
+
18
+ ## What the SDK does
19
+
20
+ One core lifecycle, several bindings, one swappable engine.
21
+
22
+ ```
23
+ construct → register? → wrap → [ check? → execute → ingest ] → deregister?
24
+ | | |
25
+ per env, optional: the boundary you declare; the client goes through
26
+ one SDK a write Stigg, and check and ingest happen inside it
27
+ about the
28
+ agent
29
+ ```
30
+
31
+ **There is no run to open.** What governs a call — who it is for, what gates it, what it reports —
32
+ is a `stiggContext`, given once at `wrap` as the default for that client and again inside any
33
+ single call's own parameters to override it. One wrapped client therefore serves every customer
34
+ and every agent, which is what a multi-tenant server actually needs.
35
+
36
+ `check` happens when the context names an `entitlement`. Omit it and the call meters only: the
37
+ request still goes out, and it is still billed.
38
+
39
+ `register` and `deregister` are optional. `register` hands back the agent id it was given — a
40
+ convenience for the next line, not a handle the rest depends on. Skip them and the agent has no
41
+ entity — calls still work, and checks and usage apply to the customer. That is deliberate:
42
+ **wrapping a client must never depend on governance being available**, and a governance-only
43
+ request (a budget, a parent) is the only thing that fails without it.
44
+
45
+ - **The agent is the entity; attribution is a dimension.** An entity upsert is idempotent and
46
+ cheap, but it is still a write — which is why provisioning is `register()` and never something a
47
+ gated call carries. Naming an `agentId` in a context attributes to that agent; it does not
48
+ create it.
49
+ - **A denial is a returned decision, not an exception** — it names which entity blocked and what
50
+ to do about it. Refusing is a commercial event (a top-up, an upgrade), not an incident. That is
51
+ `client.stigg.check()`. A **wrapped** call has nowhere to return one — the vendor's signature
52
+ owns the return type — so there it is raised as `AccessDeniedError`, and `stigg.denial(err)`
53
+ turns it back into the decision. A context that named no `entitlement` never gets there: it has
54
+ nothing to check against, so every call goes out and `check()` hands back an allow whose
55
+ `source` is `'unenforced'`.
56
+ - **Metering never breaks the call.** Ingest failures are swallowed into the `onError` hook;
57
+ enforcement failures are never swallowed. They are deliberately separate code paths.
58
+
59
+ ```ts
60
+ import { StiggAgentSDK } from '@stigg/agent-sdk-js';
61
+
62
+ // One per environment. The key can be left out entirely to read STIGG_SERVER_API_KEY, or
63
+ // replaced by a `{ baseUrl }` target that needs none - a local stand-in, or a self-hosted Stigg.
64
+ // A key for somewhere other than the hosted API names that URL second:
65
+ // new StiggAgentSDK(apiKey, 'https://stigg.internal')
66
+ // Everything after that is tuning, and every bit of it has a working default.
67
+ const stigg = new StiggAgentSDK(apiKey);
68
+
69
+ // Provisioning the agent: a write, so it is its own call. A *bare* registration is remembered per
70
+ // process and skipped the second time; this one carries a parent and a budget, and a call that
71
+ // changes placement always writes - two differing instructions cannot be collapsed into one.
72
+ await stigg.register({
73
+ customerId: 'customer-acme',
74
+ agentId: 'agent-support-bot',
75
+ entityTypeId: 'agent',
76
+ entitlement: { currencyId: 'credits' },
77
+ parentId: 'team-eng',
78
+ usageLimitOnCreate: 5,
79
+ cadence: 'P1M',
80
+ });
81
+
82
+ // Governs the client and hands back a COPY whose calls are checked and metered. The client you
83
+ // passed keeps working ungoverned.
84
+ //
85
+ // Wrapped with no customer, this client serves every tenant the process handles, and each call
86
+ // names the one it is for. The types enforce that: `customerId` is **required** on every call
87
+ // through a client wrapped this way.
88
+ const govClient = stigg.wrap(new Anthropic(), {
89
+ stiggContext: {
90
+ // Naming an entitlement is the enforcement switch. Omit it and the calls meter without
91
+ // gating - every call still goes out, and nothing is ever refused.
92
+ entitlement: { currencyId: 'credits' },
93
+ // What the calls are attributed to. The type is required with it - a guessed attribution key
94
+ // resolves no entity, and the spend would land on the customer looking exactly like a call
95
+ // that was never governed. The parent needs no key: governance walks the chain up from here.
96
+ agentId: 'agent-support-bot',
97
+ agentType: { entityTypeId: 'agent', attributionKey: 'agentId' },
98
+ // Optional extra attribution, sent verbatim on every check and ingest.
99
+ // dimensions: { tenantId: 'acme-eu' },
100
+ },
101
+ });
102
+
103
+ // The call site is otherwise untouched; the context rides in the vendor's own parameter object
104
+ // and is lifted back out before the vendor sees it.
105
+ await govClient.messages.create({
106
+ model,
107
+ max_tokens: 1024,
108
+ messages,
109
+ stiggContext: { customerId: 'customer-acme' },
110
+ });
111
+
112
+ await govClient.messages.create({
113
+ model,
114
+ max_tokens: 1024,
115
+ messages,
116
+ stiggContext: { customerId: 'customer-globex' },
117
+ });
118
+
119
+ // Drains what is still in flight and reports what this client did.
120
+ await govClient.stigg.flush();
121
+ ```
122
+
123
+ **Nothing in the setup path touches the network.** The constructor is synchronous and so is
124
+ `wrap` — resolving the engine is a probe that decides which transport serves which operation, and
125
+ both start that work rather than waiting on it. The first call that needs an engine awaits the
126
+ same probe; every call after it finds one already resolved. So there is no awaited setup step to
127
+ forget, and no call that silently runs ungoverned because setup had not finished.
128
+
129
+ The latency moves rather than disappearing: whoever makes the first call pays for the probe. If
130
+ you would rather pay at boot — and have a bad key fail there instead of inside a request —
131
+ `await stigg.init()` forces it early and hands back the resolution.
132
+
133
+ Construct one per environment and share it. A second one for the same environment is wasted
134
+ rather than wrong — and only partly a second client: `Stigg.initialize` keeps one global Node SDK
135
+ instance per process and hands it back when the API key matches, so the entitlement cache and its
136
+ socket are shared while the governance REST client is not. See
137
+ [what the cache promises](#what-the-cache-promises) for what that means for `close()`.
138
+
139
+ **A customer given at `wrap` is a default, not a scope.** One wrapped client can serve every
140
+ tenant, so naming a customer there means every call that does not override it bills *that* one —
141
+ correctly, silently, and forever if the client outlives the request. It is the right shape for a
142
+ single-tenant process and the wrong one for a server, which is why the example above leaves it
143
+ out and the types then require it per call.
144
+
145
+ ```ts
146
+ // Single-tenant process: a default is fine, and calls need no context at all.
147
+ const client = stigg.wrap(new Anthropic(), {
148
+ stiggContext: { customerId: 'customer-acme', entitlement: { currencyId: 'credits' } },
149
+ });
150
+ await client.messages.create({ model, max_tokens: 1024, messages });
151
+ ```
152
+
153
+ `wrap()` also starts warming the entitlement cache when the context names both a customer and an
154
+ entitlement, so the calls that follow read local state. That is an optimization and it is not
155
+ awaited: a failure there is logged, never thrown, and the first check falls back to asking the
156
+ server.
157
+
158
+ **`wrap` returns a copy.** The object you built is never mutated and keeps working ungoverned,
159
+ which is what makes the governed one safe to hand around. Wrapping an already-governed client
160
+ throws rather than stacking a second gate on it — there is nothing a second wrap could mean now
161
+ that the context travels with each call.
162
+
163
+ `stigg.check(context)` and `stigg.report(context, metrics)` are the same two operations with no
164
+ client to wrap, which is how a framework binding and the gateway reach them.
165
+
166
+ `wrap()` recognises the vendor from the client's own shape — there is nothing there the SDK
167
+ cannot see for itself — and throws, naming the vendors it knows, rather than handing back a
168
+ client that quietly meters nothing. Pass `recipe` to name an adapter for a vendor it cannot
169
+ identify.
170
+
171
+ **Gemini chats are wrapped in their own right.** `new GoogleGenAI()` builds its chat module over
172
+ the `models` module it holds — `new Chats(this.models, …)` — and every `Chat` keeps that
173
+ reference privately, so a chat created from a wrapped client still calls the original, unpatched
174
+ module. Wrapping the client governs `models.generateContent` and `models.generateContentStream`;
175
+ wrap the chat to govern a conversation:
176
+
177
+ ```ts
178
+ const ai = new GoogleGenAI({ apiKey });
179
+ const models = stigg.wrap(ai, { stiggContext }); // one-shot calls
180
+ const chat = stigg.wrap(ai.chats.create({ model }), { stiggContext }); // a conversation
181
+ ```
182
+
183
+ Nothing is metered twice by doing both, because the chat never reaches the patched module.
184
+
185
+ **Automatic function calling is metered in full only when you stream.** Hand Gemini a callable
186
+ tool and its SDK drives the tool loop for you — up to ten model calls for one
187
+ `generateContent` — and returns only the last response. Streamed, every round's chunks reach
188
+ this SDK and are summed; measured on the wire, 122 + 170 = 292 of 292. Not streamed, the earlier
189
+ rounds are discarded inside the vendor's SDK before it returns, so there is nothing left to read:
190
+ a two-round call billed at 315 reports 181. That turn is still metered for what it can see, is
191
+ tagged `gemini_afc_rounds`, and **raises** through `onError` with the shortfall named — it is
192
+ counted in `summary().ingestFailures` rather than left for you to notice. To meter every round,
193
+ stream the call, or set `automaticFunctionCalling: { disable: true }` and drive the loop
194
+ yourself, which makes each round a call this SDK gates and meters on its own.
195
+
196
+ Decisions describe themselves, so printing one takes no import: `decision.summary` is the log
197
+ line, `decision.message` is what to tell the user. `stigg.denial(err)` turns a caught error back
198
+ into a decision, or `undefined` when it was a real failure.
199
+
200
+ ### What a call checks, and what it reports
201
+
202
+ They are different things, and keeping them apart is what lets Stigg do the pricing instead of
203
+ your application.
204
+
205
+ `entitlement` is what a call **checks** — a credit currency or a metered feature. Reporting goes
206
+ somewhere else entirely: to `usageMapper`, one report per metric, named and valued as the vendor
207
+ stated them. Stigg converts a credit-backed feature into the currency on its own side, which is
208
+ what the API was built for and why `usage.report` answers with a credit balance.
209
+
210
+ ```
211
+ [governance] ingest input_tokens=12 cache_read_input_tokens=3 output_tokens=46 ... credits=0.94
212
+ ```
213
+
214
+ `entitlement` is also **the enforcement switch**. Name one and every call through the wrapped
215
+ client is checked before it leaves. Leave it out and nothing is checked at all: no budget is
216
+ pre-fetched, no call can be refused, and the calls meter exactly as gated ones do. There is no
217
+ separate flag, because a context with nothing to check against cannot be enforced and a flag would
218
+ only be a second place for the two to disagree — so the client says which it is, once, at `wrap`:
219
+
220
+ ```
221
+ [stigg] this client named no entitlement, so nothing is checked and no call can be refused - it
222
+ meters only. Pass `entitlement: { currencyId: 'credits' }` in `stiggContext`, at the wrap or on a
223
+ call, to gate it.
224
+ ```
225
+
226
+ The default is the vendor's own vocabulary, so **each recipe is where you read what that vendor
227
+ meters** — `anthropicRecipe.metrics` is the list, typed as literals rather than as `string`.
228
+
229
+ #### The events this SDK reports, and the meters to put behind them
230
+
231
+ Reporting defaults to Stigg's event API (see the ingest table further down), and an event is
232
+ matched by **name**. A wrapped client names each call after the call — the name someone reading
233
+ that vendor's API would have guessed — so these are what your meters filter on:
234
+
235
+ | Binding | Event name | Fields a meter can aggregate |
236
+ | ------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
237
+ | Anthropic | `anthropic.messages.create` | `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `total_tokens` |
238
+ | OpenAI (chat) | `openai.chat.completions.create` | `prompt_tokens`, `completion_tokens`, `cached_tokens`, `total_tokens` |
239
+ | OpenAI (responses) | `openai.responses.create` | `input_tokens`, `output_tokens`, `cached_tokens`, `total_tokens` |
240
+ | OpenRouter | `openrouter.chat.completions.create`, `openrouter.responses.create` | the OpenAI set, plus `cost` |
241
+ | LiteLLM | `litellm.chat.completions.create`, `litellm.responses.create` | the OpenAI set, plus `cost` |
242
+ | TypeSafe | `typesafe.systemOne` | `input_tokens`, `output_tokens`, `total_tokens` |
243
+ | Gemini | `gemini.models.generateContent`, `gemini.models.generateContentStream` | `promptTokenCount`, `candidatesTokenCount`, `cachedContentTokenCount`, `thoughtsTokenCount`, `toolUsePromptTokenCount`, `totalTokenCount` |
244
+ | Gemini — a chat | `gemini.chats.sendMessage`, `gemini.chats.sendMessageStream` | the same five |
245
+ | A gateway, out of process | `<vendor>.proxied` | whatever the proxy declared its vendor to be |
246
+
247
+ Every metric rides the event under its own name, so **one event feeds as many features as you
248
+ configure meters for** — that is why a turn costs one request however many counters it reported.
249
+ The vendor's own usage block rides along too, under a `raw.` prefix and flattened to scalars, so a
250
+ meter can read a counter this SDK does not name yet: `raw.cache_creation_input_tokens`, or a
251
+ gateway's `raw.cost` before it was scaled into whole units. Your run's `dimensions` — `agentId`
252
+ and anything else you set — are on the same event, and win any key the vendor also used.
253
+
254
+ A meter to put behind one of them, in Stigg's own terms:
255
+
256
+ ```jsonc
257
+ {
258
+ "aggregation": { "function": "SUM", "field": "output_tokens" },
259
+ "filters": [
260
+ { "conditions": [{ "field": "eventName", "operation": "EQUALS", "value": "anthropic.messages.create" }] },
261
+ ],
262
+ }
263
+ ```
264
+
265
+ > **An event reported against a name with no meter behind it returns 200 and meters nothing.** The
266
+ > event API answers with an acknowledgement, not a balance, so nothing downstream can notice on its
267
+ > own — configure the meter first, or run with `ingest: 'sync'`, which does answer.
268
+
269
+ #### Pricing them yourself
270
+
271
+ `weightedMetrics` turns a price-per-metric table into the mapper:
272
+
273
+ ```ts
274
+ import { anthropicRecipe, weightedMetrics } from '@stigg/agent-sdk-js';
275
+
276
+ const govClient = stigg.wrap(new Anthropic(), {
277
+ stiggContext: {
278
+ customerId,
279
+ entitlement: { currencyId: 'data_credit' },
280
+ // A whole credit per input token, half a credit per output token, cache reads free.
281
+ usageMapper: weightedMetrics(anthropicRecipe, 'data_credit', {
282
+ input_tokens: 1,
283
+ output_tokens: 0.5,
284
+ }),
285
+ },
286
+ });
287
+ ```
288
+
289
+ The table is keyed to that recipe's own names, so a typo does not compile. Naming a metric is
290
+ optional — an unpriced one is free, and a table that prices nothing meters nothing.
291
+
292
+ A weight is the price of **one unit** of that metric, so the example above is half a credit per
293
+ output token. Pass `{ per: 1000 }` when the numbers read better per thousand.
294
+
295
+ It also handles the three things a hand-written mapper has to get right: Stigg takes whole units
296
+ so the total rounds up, a turn that measured nothing reports nothing rather than a zero, and a
297
+ counter the response omitted is absent rather than zero. For one number without weights,
298
+ `creditsPerTokens(featureId, n)` is the short form.
299
+
300
+ #### Writing the mapper yourself
301
+
302
+ A calculation a weights table cannot express is an ordinary function, and each binding exports the
303
+ type of what it receives — `AnthropicMetrics` for Anthropic, and `OpenAIMetrics`,
304
+ `OpenRouterMetrics`, `LiteLLMMetrics`, `GeminiMetrics` and `TypeSafeMetrics` for the rest:
305
+
306
+ ```ts
307
+ import type { AnthropicMetrics, Metrics } from '@stigg/agent-sdk-js';
308
+
309
+ const dataCredits = ({ metrics = {} }: { metrics?: AnthropicMetrics }): Metrics => {
310
+ const billable = (metrics.output_tokens ?? 0) * 5 + (metrics.input_tokens ?? 0);
311
+ return billable > 0 ? { data_credit: Math.ceil(billable / 100) } : {};
312
+ };
313
+ ```
314
+
315
+ Every counter is optional, because a vendor reports cache reads only when there were some. Use
316
+ `AnthropicMetric` for one name rather than the table, and `MetricsOf<typeof someRecipe>` /
317
+ `MetricsFor<typeof someRecipe>` for a recipe you built yourself.
318
+
319
+ #### The overlap you have to know about
320
+
321
+ Vendors disagree about whether cached tokens are already counted inside the input total, and the
322
+ metrics are reported **verbatim** — because a number published under a vendor's own field name
323
+ has to match that vendor's own dashboard.
324
+
325
+ Every vendor also reports a **total**, which by definition overlaps everything beneath it. Where
326
+ the vendor publishes one it is reported verbatim — `total_tokens`, or `totalTokenCount` for
327
+ Gemini. Anthropic and TypeSafe publish none, so theirs is summed from counters that are disjoint,
328
+ which is the only case where the SDK adds anything up. **Price the total or the breakdown, never
329
+ both.**
330
+
331
+ | Vendor | Input-ish key | Includes cached tokens? |
332
+ | ---------------- | --------------- | -------------------------------------------------------- |
333
+ | Anthropic | `input_tokens` | **No** — cache keys are separate; the four sum correctly |
334
+ | OpenAI Chat | `prompt_tokens` | **Yes**, includes `cached_tokens` |
335
+ | OpenAI Responses | `input_tokens` | **Yes**, includes `cached_tokens` |
336
+ | LangChain | `input_tokens` | **Yes**, includes both cache keys |
337
+ | Gemini | `promptTokenCount` | **Yes**, includes `cachedContentTokenCount` |
338
+
339
+ Gemini overlaps in both directions at once, so it is worth stating separately: pricing
340
+ `promptTokenCount` **and** `cachedContentTokenCount` double-bills the cached tokens, while
341
+ `thoughtsTokenCount` and `toolUsePromptTokenCount` sit *outside* the two headline counters —
342
+ leave them out of a table and a reasoning model's thinking tokens, usually the expensive half of
343
+ the answer, are billed at nothing. The four add back up to the vendor's own `totalTokenCount`,
344
+ which is the arithmetic to check a table against.
345
+
346
+ So if you price `prompt_tokens` **and** `cached_tokens` for OpenAI traffic, you bill the cached
347
+ ones twice. Price one or the other, or use `normalizedMetrics` — four disjoint counters under
348
+ SDK-owned names, comparable across every vendor:
349
+
350
+ ```ts
351
+ import { normalizedMetrics } from '@stigg/agent-sdk-js';
352
+ stigg.wrap(client, { stiggContext: { customerId, usageMapper: normalizedMetrics } });
353
+ ```
354
+
355
+ OpenAI also has no raw name for cache creation, so its default set is three keys and a
356
+ `cache_creation_input_tokens` feature stays at zero for OpenAI traffic.
357
+
358
+ #### The failure that looks like success
359
+
360
+ A feature only drains a credit budget when your catalog maps it to that currency. Until it does,
361
+ every call succeeds, every ingest returns `ok`, and the budget never moves — so the call gates on a
362
+ number nothing spends. Stigg answers those reports with no credit balance, so the first one says
363
+ so:
364
+
365
+ ```
366
+ [stigg] reported input_tokens but the response carried no credit balance - this feature is not
367
+ mapped to credits, so the budget this run checks against will never drain. Map it in Stigg, or
368
+ report to the currency directly.
369
+ ```
370
+
371
+ ### Bindings, by solution
372
+
373
+ Organised by where the customer's business logic sits, not by vendor. Each has a generic core;
374
+ vendor directories are thin recipes over it.
375
+
376
+ The word "gateway" below does two jobs, so the directories do not: one you **call** is an ordinary
377
+ client and lives in `bindings/client/gateways/`, beside `bindings/client/providers/` because both
378
+ are recipes over the same wrapper. One you **operate** is the opposite binding and lives in
379
+ `bindings/proxy/`.
380
+
381
+ | Binding | Reached as | Mechanism | Can refuse? |
382
+ | ------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------ |
383
+ | Client | `stigg.wrap(client, { stiggContext })` | gates the recipe's methods on a copy, vendor matched by shape | Yes — when the context names an entitlement |
384
+ | Client — a gateway you **call** | `wrap(client, { recipe: openrouterRecipe() })` | same wrapper; the recipe bills `usage.cost` and attributes `response.model` | Yes, same as any client |
385
+ | Client — a gateway whose cost is in a **header** | `wrap(client, { recipe: litellmRecipe() })` | same wrapper; the recipe bills `x-litellm-response-cost` and attributes `response.model` | Yes, same as any client |
386
+ | Gateway you **operate** (`bindings/proxy`) | `client.stigg.headers()` → `stigg.ingestFromHeaders(...)` | attribution travels as `x-stigg-*` headers; `vendor` names the shape it observed | **No — ingest only** |
387
+
388
+ A client run that names no `entitlement` lands in the same column as the gateway — metering
389
+ without a gate — except that it is in-process, and naming one turns it back on.
390
+
391
+ #### Providers that speak the OpenAI wire format
392
+
393
+ A large part of the ecosystem exposes an OpenAI-compatible API rather than one of its own:
394
+ OpenRouter, LiteLLM, Groq, Together, Fireworks, DeepSeek, xAI and Perplexity, plus self-hosted
395
+ servers like vLLM and Ollama. There is no separate client to install for any of them. You point
396
+ the OpenAI client at their base URL, and `wrap` matches on the **shape of the client**, not on who
397
+ is at the other end of it:
398
+
399
+ ```ts
400
+ // Groq, Together, Ollama, vLLM - anything OpenAI-compatible
401
+ const client = stigg.wrap(
402
+ new OpenAI({ baseURL: 'https://api.groq.com/openai/v1', apiKey: process.env.GROQ_API_KEY }),
403
+ { stiggContext: { customerId, entitlement: { currencyId: 'credits' } } },
404
+ );
405
+ ```
406
+
407
+ So `OpenAI` in that line names the **wire format**, not the vendor being billed. What differs is
408
+ which recipe matches, and that decides what you can meter:
409
+
410
+ | Pointed at | Recipe matched | Events it emits | Reports |
411
+ | ------------------------------------ | ------------------ | --------------------------------- | --------------------------------- |
412
+ | `https://openrouter.ai/api/v1` | `openrouterRecipe` | `openrouter.*` (see below) | tokens, `cost`, model that served |
413
+ | the LiteLLM proxy URL you configured | `litellmRecipe` | `litellm.*` | tokens, `cost`, model that served |
414
+ | anything else OpenAI-compatible | `openAIRecipe` | `openai.*` | tokens only |
415
+
416
+ Each of those covers **both OpenAI surfaces**, and a surface is its own event: Chat Completions
417
+ reports `<vendor>.chat.completions.create` and the Responses API reports
418
+ `<vendor>.responses.create`. A meter filtering on one sees nothing from the other, so configure the
419
+ one your code actually calls - or both. The full list is in the event table earlier in this file.
420
+
421
+ OpenRouter is recognised by its **hostname** and LiteLLM by the base URL you hand its recipe, which is
422
+ why those two get a `cost` the token counters cannot recover. Everything else is OpenAI as far as
423
+ this SDK can tell, because that is all the client reveals.
424
+
425
+ > **One exception to that last row.** Given no base URL of its own, the LiteLLM recipe falls back to
426
+ > looking for `litellm` anywhere in the client's URL - a self-hosted proxy has no canonical hostname
427
+ > to compare against, the way OpenRouter's own hostname does. So an unrelated OpenAI-compatible
428
+ > endpoint that happens to contain the string, like `https://api.acme.com/litellm-compat/v1`, is
429
+ > claimed by it and reports under `litellm.*`. Pass `recipe: openAIRecipe` to settle it explicitly.
430
+
431
+ **The consequence worth planning for:** every provider in that last row reports under `openai.*`.
432
+ A raw-events meter cannot tell Groq from Ollama from OpenAI itself, and neither can a per-provider
433
+ budget. If you need them separated, give the call a dimension of your own —
434
+
435
+ ```ts
436
+ stiggContext: { customerId, dimensions: { provider: 'groq' } }
437
+ ```
438
+
439
+ — and filter the meter on it, or pass a recipe with its own `vendor` name, which changes the event
440
+ name to match.
441
+
442
+ > **Not covered yet:** OpenRouter's own `@openrouter/sdk` package is a different shape — its method
443
+ > is `chat.send` and its counters are camelCase — so `wrap` does not recognise it and will refuse
444
+ > the client rather than meter it silently. Use the OpenAI-compatible endpoint above.
445
+
446
+ Client recipes are data — method paths and usage extractors — so the factory wires the built-in
447
+ ones with no vendor package loaded. Nothing in `src/index.ts` pulls a vendor package in at
448
+ runtime, which is what keeps one import enough for every example.
449
+
450
+ "Gateway" covers two deployments that want opposite bindings, and the split is worth naming.
451
+
452
+ **A gateway you call** — OpenRouter as a service — returns its response into your process, and
453
+ that response carries two things no token counter can recover: the credits it actually charged
454
+ (`usage.cost`) and which model actually served after fallback routing (`response.model`). That is
455
+ an ordinary client, so it takes the ordinary client binding: `openrouterRecipe()` reads both, and
456
+ the call is gated before it ever leaves. It **is** registered by default and matched by base URL
457
+ ahead of `openAIRecipe`, because reading a richer response is not a decision anyone needs to opt
458
+ into — out of the box it reports the same token metrics the plain OpenAI recipe would, and adds
459
+ `cost`, the model that served and the gateway's own request id.
460
+
461
+ Tokens and money are different features, so both are reported and you price each in Stigg. When a
462
+ response carries no cost, the key is simply absent rather than substituted with something else.
463
+ `unitsPerCredit` (micro-credits by default) is the denomination of the `cost` feature.
464
+
465
+ **A gateway you call whose price is in a header** — LiteLLM Proxy — is the same binding with one
466
+ wrinkle worth knowing before you budget on it. LiteLLM prices every call itself, but writes the
467
+ number into `x-litellm-response-cost` rather than into the body, so `litellmRecipe()` reads the
468
+ response and not just the payload. Headers go on the wire before the model has produced a token,
469
+ so a **streamed** call cannot carry a cost at all — tokens survive a stream fine, a price does
470
+ not. So a non-streaming call reports its tokens **and** a `cost`, while a streamed one reports
471
+ the tokens alone — the same features either way, one of them simply missing where the protocol
472
+ cannot carry it.
473
+ That is the protocol rather than a limit of the binding, and the gateway example prints both
474
+ numbers side by side so the difference is not a surprise in an invoice.
475
+
476
+ **A gateway you operate** — a proxy fronting services that may not even be Node — is the one that
477
+ is not a binding in the full sense, and deliberately so: it is out-of-process and sees
478
+ **requests, not runs**. It cannot provision, cannot check against run-scoped state, and cannot
479
+ archive, and there is no client object out there to wrap. So the lifecycle splits across the
480
+ boundary — the application checks in-process, attribution travels as headers,
481
+ and the proxy only ingests. Governance never leaves the process.
482
+
483
+ A recipe reports a `Measurement`, not just token counts: `metrics` carries the vendor's own field
484
+ names and is what gets reported (a gateway puts its real spend in `metrics.cost` there, where a
485
+ token mapper would only lose it), `raw` rides an event's dimensions under a `raw.` prefix,
486
+ `dimensions` are merged over the context's own, and `idempotencyKey` lets the vendor's own request
487
+ id — stable across _its_ retries, which a local step counter cannot be — replace the default
488
+ `correlationId:step`. Returning
489
+ a bare `TokenUsage` still works and stays the common case.
490
+
491
+ ### The engine: SDK first, REST to complete
492
+
493
+ Two clients, composed per operation, and the SDK tells you which one it picked:
494
+
495
+ ```text
496
+ [governance] mode=governance check=rest report=node-sdk provisioning=rest (governance enabled)
497
+ ```
498
+
499
+ | Operation | Client | Why |
500
+ | ------------------- | ------------------------------------- | ------------------------------------------------------------------------ |
501
+ | `check` | `@stigg/typescript` (REST, `v1-beta`) | only the REST surface takes `dimensions` and returns governance `chains` |
502
+ | `ingest` | `@stigg/node-server-sdk` | it has the in-memory cache, and usage reporting is the hot path |
503
+ | provision / archive | `@stigg/typescript` (REST, `v1-beta`) | entities and assignments exist only on the governance surface |
504
+
505
+ **Ingest has two APIs, and the context picks one.** They are not variants of one call:
506
+
507
+ | | `ingest: 'async'` — the default | `ingest: 'sync'` |
508
+ | ---------------------- | ----------------------------------- | ---------------------------------------------------- |
509
+ | Call | `events.report` / `reportEvent` | `usage.report` / `reportUsageBulk` |
510
+ | Latency and rate limit | low latency, much higher limit | higher latency, lower limit |
511
+ | The next check | lags while Stigg aggregates | already includes the spend |
512
+ | What you send | the call, as one event | a `featureId` and a whole-number `value`, per metric |
513
+ | Meter type | **Raw events**, configured in Stigg | calculated / incremental |
514
+ | What comes back | an acknowledgement | a credit balance |
515
+
516
+ Async sends **one event for the whole turn**, every metric on its own dimension, because one event
517
+ feeds any number of meters — fanning out per metric buys nothing and costs a request each. Sync
518
+ sends one report per metric, batched into a single bulk call.
519
+
520
+ A metric named after the currency the context checks is neither: it draws the wallet directly, since
521
+ no raw event can reach a currency. It follows the mode only in whether it waits for the balance.
522
+
523
+ **Governance is optional.** Not every account has it. When it is missing the factory falls back
524
+ to customer-level enforcement on the Node SDK alone — every example still runs, denials just name
525
+ the customer instead of an agent:
526
+
527
+ ```text
528
+ [governance] mode=customer-level check=node-sdk report=node-sdk provisioning=unavailable
529
+ (403 GovernanceNotEnabled - customer-level fallback)
530
+ ```
531
+
532
+ Nothing declares this. The client asks the account once at init and acts on the answer, so there
533
+ is no second source of truth to disagree with the first. Without governance, checks and usage
534
+ reporting still work at the customer level — and `register()` follows that split. A bare
535
+ registration warns and hands the id back: it asked for an identity, and a call needs none. A call
536
+ carrying a **budget or a parent** throws, because there is nowhere to write the assignment, and a
537
+ run that looked governed with a budget that was never written is the failure worth refusing.
538
+
539
+ ### What the cache promises
540
+
541
+ **This package has no cache.** Checks are served by the Node server SDK's entitlement cache, which
542
+ is a far better one than a TTL over decisions could be:
543
+
544
+ - it writes the server's own measurement response back into cached usage, so a report that moved
545
+ the budget is visible to the very next check;
546
+ - it invalidates on realtime updates, so a plan change or a cancelled subscription lands without
547
+ waiting out a window;
548
+ - it applies usage-period resets at read time, rather than serving a number that expired;
549
+ - it falls back to configured entitlements when Stigg is unreachable;
550
+ - and it can be backed by **Redis**, so sibling processes share one view instead of each holding
551
+ their own.
552
+
553
+ All of that is configured through `cache`, which is the Node SDK's own configuration passed
554
+ straight through — `redis`, `memoryCacheMaxSizeBytes`, `entitlementsFallback`,
555
+ `creditEntitlementsFallback`, `realtimeUpdatesEnabled`, `enableEdge`, `offline`:
556
+
557
+ ```ts
558
+ const stigg = new StiggAgentSDK(apiKey, {
559
+ cache: { redis: { host: 'redis', port: 6379, environmentPrefix: 'prod', ttl: -1 } },
560
+ });
561
+ ```
562
+
563
+ `wrap()` primes it for the customer in the context, loading their whole entitlement state rather
564
+ than one decision — so a later check against a different entitlement is warm too, and each check
565
+ is evaluated against the amount it actually asks for.
566
+
567
+ #### If your application already has a Stigg client
568
+
569
+ Pass it, and the SDK uses it instead of building one:
570
+
571
+ ```ts
572
+ const stigg = new StiggAgentSDK(apiKey, { client: appStiggClient });
573
+ ```
574
+
575
+ This is about **ownership**, not deduplication. `Stigg.initialize` already keeps one global
576
+ instance per process and hands it back when the API key matches — so without `client` you may
577
+ well be handed the application's own, and nothing observable can tell that apart from a client
578
+ the SDK just built: the instance exposes neither its configuration nor its identity. Since
579
+ `close()` nulls that global and tears the runtime down, `stigg.close()` would then take the
580
+ application's cache and socket with it.
581
+
582
+ A client passed here is **borrowed and never closed**; one the SDK builds is owned and closed. It
583
+ replaces only the cached half — the governance REST client is still built from the API key,
584
+ because the Node SDK has no governance surface — and it is mutually exclusive with `cache`, which
585
+ configures a client the SDK is no longer building.
586
+
587
+ An earlier design cached decisions here for a `cacheTtlMs` (default 5s). It duplicated the
588
+ machinery above and degraded it: a cached allow outlived the report that spent the budget, could
589
+ not hear an invalidation, and did not carry the requested amount in its key — so an allow fetched
590
+ for one unit authorized a call asking for ten thousand. `cacheTtlMs` is now **ignored**, and
591
+ warns when set.
592
+
593
+ **The one path that caches nothing is an attributed governance check** — a check carrying
594
+ dimensions, which is how per-agent budgets are enforced. It resolves an entity, which only the
595
+ governance API can do, so it is a live call every time. That is why
596
+ `RestGovernanceEngine.capabilities.strictCheck` is `true`: there is no window in which two
597
+ processes can each approve the same budget. It costs a round trip per call, which is the right
598
+ trade in front of a multi-second model call and the wrong one to optimize away with a stale allow.
599
+
600
+ None of it applies to a context that names no `entitlement`: it warms nothing and checks nothing,
601
+ because it never asks.
602
+
603
+ #### How this SDK identifies itself
604
+
605
+ **There is no telemetry.** Nothing is emitted on a schedule, nothing is batched in the background,
606
+ and there is no second network path that could fail or leak. The SDK names itself on the requests
607
+ it was already making, and that is all:
608
+
609
+ | Half | What it sends |
610
+ | --- | --- |
611
+ | Cached reads and ingest (GraphQL) | `apollographql-client-name: agent-sdk-js`, `apollographql-client-version: <this package's version>` |
612
+ | Governance (REST) | `X-SDK-CLIENT-NAME: agent-sdk-js`, `X-SDK-CLIENT-VERSION: <this package's version>` |
613
+
614
+ No customer data, no prompts, no model output — the package name and its version, next to the
615
+ process identity (`X-SDK-INSTANCE-ID` and friends) the Node SDK already sends.
616
+
617
+ Two things it deliberately does **not** do. It does not send a distinct `source`: that header is a
618
+ server-side enum with no member for this SDK, the API groups on it, so a new member is a server
619
+ change that has to land first — the client name carries the distinction until then. And it does not
620
+ rename a client you passed in through `client`, which keeps whatever name your application gave it;
621
+ `Stigg.initialize` may also hand back a client your application built first, in which case the same
622
+ applies. Both cases are correct — the name belongs to whoever owns the client.
623
+
624
+ ## 0. Try it with no setup at all
625
+
626
+ ```bash
627
+ yarn workspace @stigg/agent-sdk-js run example-client-anthropic # or -openai, -gemini, -openrouter, -litellm
628
+ yarn workspace @stigg/agent-sdk-js run example-gateway # or example-gateway-litellm
629
+ ```
630
+
631
+ The full list, with what each one demonstrates, is in [section 6](#6-run-the-examples).
632
+
633
+ The vendor mock speaks the real wire formats, so the vendor SDK parses a genuine response and
634
+ every field the examples read (`usage`, `model`, `cost`, tool calls) is populated. The Stigg mock
635
+ implements real entities, budgets and hierarchy, so a demo genuinely hits a denial and can name
636
+ which entity caused it. Only the prose and the storage are fake.
637
+
638
+ To force the mocks even when keys **are** present — useful when a key is valid but the account
639
+ has no credits — set `AGENT_SDK_OFFLINE=1` in `.env`.
640
+
641
+ ## 1. Prerequisites
642
+
643
+ - **Node 22** — the version in the repo's `.nvmrc`. Check with `node --version`.
644
+ - **Yarn 4** via Corepack:
645
+
646
+ ```bash
647
+ corepack enable
648
+ ```
649
+
650
+ ## 2. Get an Anthropic API key (optional)
651
+
652
+ Used by the client examples. Skip this to stay on the offline mock.
653
+
654
+ 1. Go to **<https://console.anthropic.com>** and sign up or sign in.
655
+ 2. Open **Billing** and add a payment method or buy credits. A brand-new org has no balance,
656
+ and requests fail with `400 credit balance is too low` until you do.
657
+ 3. Open **Settings → API Keys** and click **Create Key**. Give it a name.
658
+ 4. **Copy the key immediately** — it is shown only once. It starts with `sk-ant-`.
659
+ 5. Put it in `.env` as `AGENT_SDK_ANTHROPIC_API_KEY` (step 5 below).
660
+
661
+ The examples default to the `claude-opus-5` model. Override with `AGENT_SDK_ANTHROPIC_MODEL` in `.env`.
662
+
663
+ ## 3. Gateway access (optional)
664
+
665
+ Used by the gateway examples. Skip this entirely to stay on the offline mock, which speaks both
666
+ wire formats — including LiteLLM's header-borne cost, and its absence on a stream.
667
+
668
+ ### OpenRouter
669
+
670
+ 1. Go to **<https://openrouter.ai>** and sign in with Google, GitHub, or email.
671
+ 2. Open **Settings → Credits** and add credits (a few dollars is plenty for these examples).
672
+ 3. Go to **<https://openrouter.ai/keys>** and click **Create Key**.
673
+ 4. Optionally set a **credit limit** on the key — a hard cap on what this key can ever spend.
674
+ Recommended while experimenting.
675
+ 5. Copy the key (starts with `sk-or-`) and put it in `.env` as `AGENT_SDK_OPENROUTER_API_KEY`.
676
+
677
+ The example asks for `AGENT_SDK_OPENROUTER_MODEL` and falls back through
678
+ `AGENT_SDK_OPENROUTER_FALLBACK_MODELS`.
679
+ Both are model slugs — browse or verify them at **<https://openrouter.ai/models>**.
680
+
681
+ ### LiteLLM
682
+
683
+ There is no signup: a LiteLLM proxy is something you host, so this is a URL rather than an account.
684
+
685
+ 1. Start one — `pip install 'litellm[proxy]'`, then `litellm --model gpt-4o --port 4000`. It needs
686
+ the upstream key for whatever it fronts (`OPENAI_API_KEY`, and so on) in its own environment.
687
+ 2. Put its URL in `.env` as `AGENT_SDK_LITELLM_BASE_URL`. The default is `http://localhost:4000` —
688
+ note a **local Stigg** uses port 4000 too, so move one of them if you run both.
689
+ 3. Put a virtual key in `AGENT_SDK_LITELLM_API_KEY` if the proxy requires one (`--master_key`, or
690
+ a key minted through `/key/generate`). Leave it blank and the examples use the offline mock.
691
+
692
+ `AGENT_SDK_LITELLM_MODEL` is a **model group** from the proxy's own config, not a provider slug.
693
+ A group is free to resolve to a different deployment, and that difference is the routing drift
694
+ the examples flag.
695
+
696
+ ## 4. Point at a Stigg environment (optional)
697
+
698
+ One key and one URL, pointed at whichever environment you mean. The key is environment-bound, so
699
+ it and the URL always move together — there is no separate profile name to keep in sync with them.
700
+
701
+ | Environment | `STIGG_BASE_URL` |
702
+ | ----------- | --------------------------------------------------------------- |
703
+ | production | `https://api.stigg.io` (the default when the variable is blank) |
704
+ | staging | `https://api-staging.stigg.io` |
705
+ | local | `http://localhost:4000` |
706
+
707
+ Get a server key for that environment from **app.stigg.io → Integrations → API keys** and put it
708
+ in `STIGG_API_KEY`.
709
+
710
+ **Leave `STIGG_API_KEY` blank and everything still runs** against a built-in Stigg mock, with no
711
+ account at all — the same rule the vendor keys follow. `AGENT_SDK_OFFLINE=1` forces the mocks even
712
+ when keys are present, which is what you want when a key is valid but the account has no credits.
713
+
714
+ Two things adjust themselves to the URL, so you do not configure them twice:
715
+
716
+ - The mock answers both transports — REST and `/graphql` — so the engine resolves exactly as it
717
+ does against a real account: governance checks and provisioning over REST, ingest and
718
+ unattributed checks over the Node SDK. The line the SDK prints at startup says which it picked.
719
+ - Against `localhost` the Node SDK's edge cache and realtime socket are switched off, because a
720
+ local API serves neither and the SDK maps a localhost base to edge port 3060.
721
+
722
+ ### Seed the environment
723
+
724
+ ```bash
725
+ yarn workspace @stigg/agent-sdk-js run example-setup # 5-credit agent budget
726
+ yarn workspace @stigg/agent-sdk-js run example-setup --agent-limit 6 # raise it
727
+ yarn workspace @stigg/agent-sdk-js run example-setup --no-governance # seed without entities
728
+ yarn workspace @stigg/agent-sdk-js run example-setup --archive # clean up, bottom-up
729
+ ```
730
+
731
+ `--no-governance` describes this invocation only: it skips the entity and budget steps, so the
732
+ account is seeded without a tree. It is not a mode the examples inherit — offline, each one starts
733
+ its own mock, and that mock has governance enabled. To see customer-level enforcement end to end,
734
+ use an account that genuinely has no governance.
735
+
736
+ Idempotent per step, and safe to re-run — but not safe to run twice **at once**: the credit grant
737
+ reads the balance and then grants, with nothing between the two, so two concurrent invocations can
738
+ both decide the pool is short and both fund it. Run it once at a time. Steps are gated by what the
739
+ account actually has:
740
+
741
+ | Step | Needs governance? | What it does |
742
+ | -------------------------- | ----------------- | -------------------------------------------------------------------------------- |
743
+ | 1. customer | no | creates it if missing |
744
+ | 2. credit pool | no | grants credits when the balance is below `--pool-min`, guarded by a balance read |
745
+ | 3. entity types + entities | **yes** | `team` (attribution key `teamId`) and `agent` (`agentId`) |
746
+ | 4. assignments | **yes** | One agent per example, each = 5 per `P1M`, all parented to `team-research` |
747
+
748
+ Each example registers **its own agent** — `agent-support-bot-anthropic`, `-openai`, `-gemini`, `-openrouter`,
749
+ `-litellm`, and the base `agent-support-bot` for the gateway examples. Budgets attach to the agent, so
750
+ sharing one would mean running the OpenAI example spends what the Anthropic example had left. The
751
+ shared parent team keeps the ancestor rule demonstrable, and its budget is sized so one agent
752
+ exhausting itself does not block the others:
753
+
754
+ ```text
755
+ team-research (team) usage=0/30
756
+ agent-support-bot usage=0/5
757
+ agent-support-bot-anthropic usage=0/5
758
+ agent-support-bot-openai usage=0/5
759
+ agent-support-bot-openrouter usage=0/5
760
+ agent-support-bot-litellm usage=0/5
761
+ ```
762
+
763
+ **The client examples provision their own agent** — the seed prepares the account (entity types,
764
+ the parent team, a funded pool). That is the more realistic split: an admin sets up the account, an
765
+ application provisions its own agents at runtime. `register()` carries it:
766
+
767
+ One caveat about the cap, because the two halves can overlap. `usageLimitOnCreate` is exactly that
768
+ — on create — so if the seed has already written an assignment for that agent, the application's
769
+ cap is **not** applied and the seeded one stands. That is the point of the option (an admin who
770
+ raises a budget out of band should not have it stamped back on every process start), but it does
771
+ mean `--agent-limit` on the seed wins over the number below. Use `usageLimit` instead if the
772
+ application is meant to own it.
773
+
774
+ ```ts
775
+ await stigg.register({
776
+ customerId: 'customer-acme',
777
+ agentId: 'agent-support-bot-anthropic',
778
+ entityTypeId: 'agent',
779
+ // A budget is written over (entity x entitlement), so a call carrying one names it. `register`
780
+ // refuses a placement without it rather than provisioning an agent whose cap lands nowhere.
781
+ entitlement: { currencyId: 'credits' },
782
+ parentId: 'team-research',
783
+ usageLimit: 5,
784
+ cadence: 'P1M',
785
+ });
786
+ ```
787
+
788
+ A cap is **not** a field on the entity. An entity is just a node — id, type, display name,
789
+ metadata — and a limit is an _assignment_ over `(entity × capability)`. So that one call is two
790
+ idempotent upserts underneath: `PUT …/entities` then `PUT …/assignments`. It is also why
791
+ `parentId` rides the assignment rather than the entity: the hierarchy is per-capability, so one
792
+ agent can sit under different parents for different currencies.
793
+
794
+ `usageLimit` is tri-state — a number caps it, `null` tracks without blocking (the observe-only
795
+ rollout), and omitting it leaves whatever is already assigned untouched. Governance requires both
796
+ it and `cadence` when it creates a budget, which is why both are passed above.
797
+
798
+ The pool is deliberately much larger than the agent budget, so a demo denial comes from the
799
+ agent's own cap (`BudgetExceeded`) rather than an empty wallet (`InsufficientCredits`). Those are
800
+ different products — an upsell versus an escalation to whoever set the budget — and the SDK keeps
801
+ them distinct all the way to the message the user sees.
802
+
803
+ ## 5. Configure and install
804
+
805
+ Two files, deliberately separate. Both are git-ignored, and each has a committed `.template`
806
+ beside it to copy from.
807
+
808
+ **`.env`** (from `.env.template`) — credentials and SDK configuration. Everything in it is either a
809
+ secret or a real setting you would also make in your own application.
810
+
811
+ **`examples/demo.env`** (from `examples/demo.env.template`) — the demo's fixtures: the tenant, the
812
+ team, the agent. Nothing in it is configuration — a real application knows its tenant from the
813
+ request it is serving, never from an env file.
814
+
815
+ Neither file is required **against the mocks**: every fixture default is compiled into the
816
+ examples, so with no files and no keys at all they still run end to end. Those defaults are
817
+ mock-only. Once `STIGG_API_KEY` is set the examples stop inventing ids for an environment they
818
+ can actually write to — `AGENT_SDK_DEMO_CUSTOMER_ID` becomes required, and the rest degrade to a
819
+ smaller demo (see the fixtures table below).
820
+
821
+ `.env` is loaded first and is never overwritten, so setting one of the `AGENT_SDK_DEMO_*` names
822
+ there (or exporting it in your shell) overrides the fixture without editing `demo.env`.
823
+
824
+ ### `.env` — credentials and endpoints
825
+
826
+ | Variable | Default | Purpose |
827
+ | -------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
828
+ | `AGENT_SDK_ANTHROPIC_API_KEY` | — (falls back to `ANTHROPIC_API_KEY`) | Layers 1 and 3 |
829
+ | `AGENT_SDK_ANTHROPIC_MODEL` | `claude-opus-5` | Claude model for layers 1 and 3 |
830
+ | `AGENT_SDK_OPENAI_API_KEY` | — (falls back to `OPENAI_API_KEY`) | The OpenAI client example |
831
+ | `AGENT_SDK_OPENAI_MODEL` | `gpt-5` | Model for the OpenAI client example |
832
+ | `AGENT_SDK_OPENROUTER_API_KEY` | — (falls back to `OPENROUTER_API_KEY`) | Layer 2 |
833
+ | `AGENT_SDK_GEMINI_API_KEY` | — | Falls back to `GEMINI_API_KEY` / `GOOGLE_API_KEY`. Absent, the Gemini example runs on the built-in mock |
834
+ | `AGENT_SDK_GEMINI_MODEL` | `gemini-3.8-flash` | Model for the Gemini client example |
835
+ | `AGENT_SDK_OPENROUTER_MODEL` | `anthropic/claude-opus-5` | Primary model requested from the gateway |
836
+ | `AGENT_SDK_OPENROUTER_FALLBACK_MODELS` | `anthropic/claude-sonnet-5,openai/gpt-5` | Comma-separated fallbacks tried in order |
837
+ | `AGENT_SDK_LITELLM_API_KEY` | — (falls back to `LITELLM_API_KEY`) | Layer 2. Blank ⇒ the offline mock |
838
+ | `AGENT_SDK_LITELLM_BASE_URL` | `http://localhost:4000` | Your own proxy — self-hosted, so there is no public default |
839
+ | `AGENT_SDK_LITELLM_MODEL` | `gpt-5` | A model **group** from the proxy's config, not a provider slug |
840
+ | `STIGG_API_KEY` | — | **Server** key (`server-…`). A publishable `client-…` key fails every call with `IdentityForbidden`. Blank ⇒ the built-in Stigg mock |
841
+ | `STIGG_BASE_URL` | `https://api.stigg.io` | Which environment that key belongs to |
842
+
843
+ ### What the examples gate and meter against — in `examples/demo.env`
844
+
845
+ These moved out of `.env`, because they fail the test that separates the two files: **if you would
846
+ change it to play with an example, it is a fixture.** A real application decides every one of them
847
+ in code — what it bills against is part of its pricing, not of its deployment.
848
+
849
+ The SDK's own settings are arguments rather than variables, which is why none of them appear here:
850
+ `new StiggAgentSDK(apiKey, baseUrl, { cache, client, timeoutMs, logger, onError })` for the
851
+ environment, and `wrap(client, { stiggContext })` — overridable inside any call's own parameters —
852
+ for what a call is governed by.
853
+
854
+ | Variable | Default | Purpose |
855
+ | -------------------------------- | ----------------------- | ---------------------------------------------------------------------------------- |
856
+ | `AGENT_SDK_DEMO_CURRENCY_ID` | `credits` *(mock only)* | `stiggContext.entitlement` — credit currency to budget against. The default is the examples' own and applies only offline; the SDK has none. Naming neither this nor a feature is legal and means the calls **meter without gating** — `entitlement` is itself the enforcement switch. `two-agents` and the gateway examples refuse to run that way, since a budget draining is their whole subject |
857
+ | `AGENT_SDK_DEMO_FEATURE_ID` | — | `stiggContext.entitlement` — metered feature instead; exclusive with the above |
858
+ | `AGENT_SDK_DEMO_EVENT_NAME` | — | `stiggContext.eventName` — overrides the name derived from the vendor call |
859
+
860
+ ### `examples/demo.env` — demo fixtures
861
+
862
+ Scaffolding, not configuration. Your own application supplies all of this from its own context.
863
+
864
+ Each fixture has **three** states, because "no `demo.env` at all" and "I want no agent" are
865
+ different things and an absent variable cannot mean both:
866
+
867
+ | The variable is… | Against the mock | With a real `STIGG_API_KEY` |
868
+ | --------------------- | ----------------------------- | ----------------------------------------------- |
869
+ | **absent** | the default below | unset — the demo degrades, or says what it needs |
870
+ | **set** | that value | that value |
871
+ | **present but blank** | unset, deliberately | unset, deliberately |
872
+
873
+ The defaults are mock-only on purpose: against an environment you can write to, a guessed
874
+ `customer-acme` is either someone else's tenant or one nobody asked for.
875
+
876
+ | Variable | Default *(mock only)* | Unset ⇒ |
877
+ | ------------------------------------- | --------------------- | --------------------------------------------------------------------- |
878
+ | `AGENT_SDK_DEMO_CUSTOMER_ID` | `customer-acme` | **Error.** Every governance call is scoped to a tenant, and the examples will not invent one |
879
+ | `AGENT_SDK_DEMO_TEAM_ID` | `team-research` | The agents are **roots** — no ancestor budget binds them. `two-agents` needs one and says so |
880
+ | `AGENT_SDK_DEMO_AGENT_ID` | `agent-support-bot` | **No agent at all**: checks and usage land on the customer's own pool. Blank it to see that path against the mock. Each client example appends its vendor (`…-anthropic`) |
881
+ | `AGENT_SDK_DEMO_ENTITY_TYPE_ID` | `agent` *(mock only)* | `stiggContext.agentType.entityTypeId` — the entity type agents are provisioned as |
882
+ | `AGENT_SDK_DEMO_ATTRIBUTION_KEY` | `agentId` *(mock only)* | `stiggContext.agentType.attributionKey` — the key that type identifies its agents under. **Both or neither**: the SDK defaults neither half, because a guessed key resolves no entity and the spend lands on the customer looking exactly like an ungoverned call |
883
+ | `AGENT_SDK_OFFLINE` | empty | Set to `1` to force the mocks even when keys are present |
884
+ | `AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL` | empty | Mock only: make the runtime check return `503` |
885
+
886
+ Install dependencies from the **repo root**:
887
+
888
+ ```bash
889
+ corepack enable
890
+ yarn install
891
+ ```
892
+
893
+ ## 6. Run the examples
894
+
895
+ All commands work from anywhere in the repo, and all of them run with no keys at all — except
896
+ `example-client-typesafe`, whose vendor the built-in mock does not speak. That one still needs no
897
+ Stigg account.
898
+
899
+ | Command | Runs | What it shows |
900
+ | --------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
901
+ | `example-client-anthropic` | [`client/anthropic.ts`](./examples/client/anthropic.ts) | A governed chat. One `wrap()` gates and meters every turn |
902
+ | `example-client-openai` | [`client/openai.ts`](./examples/client/openai.ts) | The same script, a different vendor — governance is not vendor-specific work |
903
+ | `example-client-gemini` | [`client/gemini.ts`](./examples/client/gemini.ts) | The same script again, on a vendor that counts in its own names and streams running totals |
904
+ | `example-client-openrouter` | [`client/openrouter.ts`](./examples/client/openrouter.ts) | `openrouterRecipe()` billing the credits actually charged instead of a token count |
905
+ | `example-client-litellm` | [`client/litellm.ts`](./examples/client/litellm.ts) | The same script against a self-hosted proxy. Streams, so it meters tokens — see the gateway example for why |
906
+ | `example-client-typesafe` | [`client/typesafe.ts`](./examples/client/typesafe.ts) | A non-chat client: typed questions, batched or one per call. **Needs a real TypeSafe key** |
907
+ | `example-two-agents` | [`client/two-agents.ts`](./examples/client/two-agents.ts) | Two agents, one team: a sibling denied by its own cap, then the survivor denied by the team above it |
908
+ | `example-gateway-openrouter`| [`gateway/openrouter.ts`](./examples/gateway/openrouter.ts) | The two gateway topologies: calling one gates, operating one only ingests. `example-gateway` runs this one |
909
+ | `example-gateway-litellm` | [`gateway/litellm.ts`](./examples/gateway/litellm.ts) | The same two topologies when the cost is in a header: real spend, then tokens, from one request |
910
+ | `example-setup` | [`shared/setup-governance.ts`](./examples/shared/setup-governance.ts) | Seeds a customer, a pool and per-entity budgets. Idempotent |
911
+
912
+ `example-client` is an alias for the Anthropic one. Prefix any of them with `AGENT_SDK_OFFLINE=1`
913
+ to force the mocks even when keys are present.
914
+
915
+ ### Layer 1 — client abstraction (interactive chat)
916
+
917
+ An interactive, multi-turn chat, governed end to end, against any of five vendors. Type `exit`,
918
+ `quit`, an empty line, or press **Ctrl-D** / **Ctrl-C** to end it.
919
+
920
+ ```bash
921
+ yarn workspace @stigg/agent-sdk-js run example-client-anthropic # also: example-client
922
+ yarn workspace @stigg/agent-sdk-js run example-client-openai
923
+ yarn workspace @stigg/agent-sdk-js run example-client-gemini
924
+ yarn workspace @stigg/agent-sdk-js run example-client-openrouter
925
+ yarn workspace @stigg/agent-sdk-js run example-client-litellm
926
+ ```
927
+
928
+ All five run [the same script](./examples/client/core/chat.ts) — same governance wiring, same
929
+ output, not one line of it vendor-specific. What differs is a helper per vendor
930
+ ([anthropic](./examples/client/anthropic.ts), [openai](./examples/client/openai.ts),
931
+ [gemini](./examples/client/gemini.ts), [openrouter](./examples/client/openrouter.ts),
932
+ [litellm](./examples/client/litellm.ts)) holding the four things that genuinely differ:
933
+ which key to read, how to build the client, how to stream a turn, and where usage sits on the
934
+ response. That is the same split the SDK makes internally, for the same reason.
935
+
936
+ All five report the names their own vendor used, which is why the ingest lines differ between
937
+ them: Anthropic states `input_tokens`/`output_tokens`, OpenAI states `prompt_tokens`/
938
+ `completion_tokens`, and Gemini states `promptTokenCount`/`candidatesTokenCount` — plus a
939
+ `thoughtsTokenCount` that is billed as output and counted beside the candidates rather than
940
+ inside them. Run `example-gateway-litellm` to watch a gateway add `cost` beside them.
941
+
942
+ ```text
943
+ [governance] stigg=in-process mock customer=customer-acme currency=credits
944
+ [stigg] mode=governance check=rest (node-sdk when unattributed) report=node-sdk provisioning=rest (governance enabled)
945
+ [stigg] agent agent-support-bot-anthropic registered under customer customer-acme
946
+
947
+ you> what is metering?
948
+ [governance] check hasAccess=true remaining=5 source=rest
949
+ assistant> Metering is the practice of recording how much of a service ...
950
+ [usage:turn 1] input=12 output=46 cache_read=3 cache_creation=0
951
+ [governance] ingest input_tokens=12 cache_read_input_tokens=3 output_tokens=46 key=9d63...:1 ok=true credits=0.94
952
+
953
+ ...
954
+
955
+ you> one more
956
+ [governance] check hasAccess=false remaining=0.5742 deniedBy=agent-support-bot-anthropic reason=BudgetExceeded remedy=raise-budget source=rest
957
+ Budget exhausted for agent-support-bot-anthropic. Ask whoever set this budget to raise it, or wait for the next reset.
958
+ Raise the budget in another shell, then just keep typing - the next message re-checks:
959
+ yarn workspace @stigg/agent-sdk-js run example-setup --agent-limit 6
960
+ ```
961
+
962
+ The chat stays open after a denial. Run the suggested command in another shell and keep typing:
963
+ because denials are never cached, the next message goes through. That is the whole point of
964
+ making a refusal a commercial event rather than an error.
965
+
966
+ Every check reads `source=rest`, and `remaining` falls as the ingests land. This is an attributed
967
+ check — the agent travels as a dimension, so it resolves an entity and only the governance API can
968
+ answer it. Nothing is served from a decision cache, which is why the denial arrives on the first
969
+ call that actually exceeds the budget rather than one window later.
970
+
971
+ ### Layer 2 — gateways, in both directions
972
+
973
+ The same prompt through OpenRouter three times: wrapped with `openrouterRecipe()` (non-streaming,
974
+ then streaming), then once more down the header path, so the two topologies sit side by side.
975
+
976
+ ```bash
977
+ yarn workspace @stigg/agent-sdk-js run example-gateway
978
+ ```
979
+
980
+ ```text
981
+ --- wrapped, non-streaming ---
982
+ requested model : anthropic/claude-opus-5
983
+ served model : anthropic/claude-sonnet-5 <- routing drift
984
+ cost (credits) : 0.00108
985
+ prompt tokens : 21 (cached: 7)
986
+ completion tok. : 39 (reasoning: 4)
987
+ [governance] hasAccess=true remaining=25000 source=rest
988
+ [governance] ingest prompt_tokens=21 completion_tokens=39 cached_tokens=7 cost=1080 key=gen-mock... ok=true
989
+
990
+ --- header path, non-streaming ---
991
+ ...
992
+ [governance] proxy ingest prompt_tokens=21 completion_tokens=39 cached_tokens=7 key=gen-mock... ok=true
993
+ (ingest-only: no check on this path, and tokens not cost - the mapper cannot see usage.cost)
994
+ ```
995
+
996
+ The contrast is the whole point. Both report the same three token counters; only the wrapped call
997
+ also reports **`cost=1080`** — 0.00108 credits in micro-credits, what the gateway really charged —
998
+ attributed to the model that actually served. The header path carries a usage report rather than a
999
+ price, so the one feature that says what the call cost in money is simply missing from it. Same
1000
+ request, same seams; the difference is a feature, not a denomination.
1001
+
1002
+ If the primary model is down, rate-limited, or refuses, OpenRouter serves a fallback slug instead
1003
+ and `served model` differs — flagged as `<- routing drift`, which silently changes COGS. The
1004
+ recipe puts that slug on the ingest as a `model` dimension, so the drift is visible in Stigg and
1005
+ not just in the log.
1006
+
1007
+ The `cost` feature is denominated by the recipe: `openrouterRecipe()` reports micro-credits by
1008
+ default, because OpenRouter charges fractional credits and Stigg ingests whole units. Set
1009
+ `unitsPerCredit` to match however the Stigg feature is defined. When a response carries no cost at
1010
+ all — BYOK, and a few upstreams — the `cost` key is simply absent; the token metrics were never
1011
+ conditional on it.
1012
+
1013
+ #### When the price is in a header
1014
+
1015
+ LiteLLM makes the same comparison and splits the wrapped call in two, because for this gateway
1016
+ _how_ you called it decides whether the price is knowable at all:
1017
+
1018
+ ```bash
1019
+ yarn workspace @stigg/agent-sdk-js run example-gateway-litellm
1020
+ ```
1021
+
1022
+ ```text
1023
+ --- wrapped, non-streaming ---
1024
+ requested group : gpt-5
1025
+ served model : gpt-5-2025-08-07 <- routing drift
1026
+ cost (USD) : 0.001005
1027
+ [governance] ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 cost=1005 key=chatcmpl-... ok=true
1028
+
1029
+ --- wrapped, streaming ---
1030
+ cost (USD) : (not reported - the header precedes the body)
1031
+ [governance] ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 key=chatcmpl-... ok=true
1032
+
1033
+ --- header path, non-streaming ---
1034
+ [governance] proxy ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 key=chatcmpl-... ok=true
1035
+ (ingest-only: no check on this path, and tokens not cost - what crosses the boundary is a usage
1036
+ report, so the price the proxy computed has nowhere to go)
1037
+ ```
1038
+
1039
+ Same proxy, same recipe, same prompt. The tokens are identical in all three; what differs is
1040
+ whether `cost` is there at all. The proxy knew the price every time — it computed it — but a
1041
+ header is flushed before generation starts, so the streamed call has nothing to read
1042
+ ([BerriAI/litellm#12689](https://github.com/BerriAI/litellm/issues/12689)), and the ingest-only
1043
+ boundary carries a usage report rather than a price. Gating is unaffected throughout: both wrapped
1044
+ legs are refused before the request leaves, whatever they end up reporting.
1045
+
1046
+ So if you budget in spend against LiteLLM, price the `cost` feature and know that streamed turns
1047
+ will not contribute to it — the token features still will. `litellmRecipe()` reports micro-dollars
1048
+ by default; set `unitsPerDollar` to match however the Stigg feature is defined.
1049
+
1050
+ ### Seeing the fail-closed path
1051
+
1052
+ The governance check is fail-closed server-side: it returns `503` rather than silently allowing.
1053
+
1054
+ ```bash
1055
+ AGENT_SDK_OFFLINE=1 AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL=1 \
1056
+ yarn workspace @stigg/agent-sdk-js run example-client-anthropic
1057
+ # [stigg] check failed: check: 503 governance cache unavailable
1058
+ # [governance] check hasAccess=false reason=EngineError remedy=retry source=policy
1059
+
1060
+ # The explicit opt-in - refuse the call when the check itself could not be made. An SDK option,
1061
+ # not an env var, and overridable per call through `stiggContext`:
1062
+ # new StiggAgentSDK(apiKey, baseUrl, { checkErrorAction: 'deny' })
1063
+ ```
1064
+
1065
+ `allow` is the default: a soft limit, so an unreachable Stigg does not take the application down
1066
+ with it. `deny` mirrors the server's own fail-closed behaviour and is a decision worth making
1067
+ deliberately — "never break the customer's run" and "never spend past a budget" are not the same
1068
+ promise, and only you know which one this call is for.
1069
+
1070
+ ## 7. Typecheck, lint and test
1071
+
1072
+ ```bash
1073
+ yarn workspace @stigg/agent-sdk-js run build # typecheck, then emit dist/ - what gets published
1074
+ yarn workspace @stigg/agent-sdk-js run lint
1075
+ yarn workspace @stigg/agent-sdk-js run test # node:test via tsx; no network, literal fakes
1076
+ ```
1077
+
1078
+ `build` is two passes over two configs, and the split is the point. `tsconfig.json` is the
1079
+ typechecker's view: it covers `src`, `examples`, `test` and `testing`, and emits nothing.
1080
+ `tsconfig.build.json` compiles the two entry points - `src/index.ts` and `src/internal.ts` - and
1081
+ whatever they import, so only `src/` reaches `dist/`. It clears `dist/` first, because a module
1082
+ that was renamed or deleted would otherwise sit there and ship.
1083
+
1084
+ Either pass resolves workspace dependencies to their built `.d.ts` (see `paths` in
1085
+ `tsconfig.json`) rather than to their source, which is compiled with different flags; Nx builds
1086
+ them first.
1087
+
1088
+ ## 8. Troubleshooting
1089
+
1090
+ | Symptom | Cause and fix |
1091
+ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1092
+ | `403 GovernanceNotEnabled` | The account has no governance. Checks and usage still work; `register()` throws, because there is nowhere to create the agent entity. Contact Stigg to enable it. |
1093
+ | `No STIGG_API_KEY` | Set it to a server key for the environment in `STIGG_BASE_URL`, or leave it blank to run on the built-in mock. |
1094
+ | `503` and every turn denied | The governance cache is unavailable and `checkErrorAction` is `deny`. The default is `allow`, so this is a policy that was asked for; drop it to keep running through the outage, or relax a single call with `stiggContext: { checkErrorAction: 'allow' }`. |
1095
+ | `usage value must be a whole number` | Stigg rejects fractional usage under `'sync'`. Raise the unit size in your `usageMapper` — `creditsPerTokens(id, n)` — or map to a coarser unit. |
1096
+ | `... is backed by a raw-events meter` | A `sync` run reported to a feature only a meter can fill. Report that run with `ingest: 'async'` (the default), or point it at a feature whose value your application computes. |
1097
+ | Ingest reports `ok=true` but usage stays 0 | Under `async` an acknowledgement is all the event API returns. Check a raw-events meter filters on the event name (see the event table above); against a **local** Stigg, also that the `ingest-usage-stream-processor` and `calculate-usage-stream-processor` lambdas are running. `ingest: 'sync'` answers with a balance and will say so instead. |
1098
+ | Still denied right after a top-up | Budgets reset per cadence window, so a spent agent stays spent. Re-run `example-setup --agent-limit N` with a higher limit, or use a new `AGENT_SDK_DEMO_AGENT_ID`. |
1099
+ | Denial names the customer, not the agent | Customer-level mode (no governance), or the context named no `agentId` and carried no attribution dimensions of its own. |
1100
+ | Denial names an agent you did not pass | A `dimensions` key resolves an entity that already exists in the account. The agent belongs in `stiggContext.agentId`; `dimensions` gates on whatever it names, registered here or not. |
1101
+ | `401` / `authentication_error` | The key is present but wrong. Clear it from `.env` to fall back to the offline mock, or fix it. |
1102
+ | `400 credit balance is too low` (Anthropic) | Add credits under Billing, or set `AGENT_SDK_OFFLINE=1` to run on the mock. |
1103
+ | `402` / insufficient credits (OpenRouter) | Add credits under Settings → Credits, or set `AGENT_SDK_OFFLINE=1` to run on the mock. |
1104
+ | `400` naming a model (OpenRouter) | A slug in `AGENT_SDK_OPENROUTER_MODEL` / `AGENT_SDK_OPENROUTER_FALLBACK_MODELS` does not exist. Check <https://openrouter.ai/models>. |
1105
+ | `ECONNREFUSED` on port 4000 (LiteLLM) | No proxy is running there. Start one (`litellm --model gpt-4o --port 4000`), fix `AGENT_SDK_LITELLM_BASE_URL`, or clear `AGENT_SDK_LITELLM_API_KEY` to fall back to the mock. |
1106
+ | `400` / unknown model group (LiteLLM) | `AGENT_SDK_LITELLM_MODEL` must name a group from the proxy's own config, not a provider slug. List them with `curl $AGENT_SDK_LITELLM_BASE_URL/v1/models`. |
1107
+ | LiteLLM turns bill `units=1`, not spend | Expected on a streamed call: `x-litellm-response-cost` is flushed before the body, so there is no cost to read. Call non-streaming to bill real spend. |
1108
+ | `Cannot find module` | Run `yarn install` from the repo root, not from this directory. |
1109
+ | Chat prints nothing and exits | Input is not a TTY (piped input hits end-of-file). Try `printf 'hi\nexit\n' \| yarn workspace @stigg/agent-sdk-js run example-client-anthropic`. |
1110
+ | A CLI hangs after the last line | The Node SDK keeps a background cache alive. Call `gov.flush({ close: true })`, as the examples do. |
1111
+
1112
+ ## 9. Layout
1113
+
1114
+ ```
1115
+ src/
1116
+ index.ts the whole public surface, one import
1117
+ stigg-agent-sdk.ts the composition root: the one file that knows both sides
1118
+ types.ts the vocabulary, and every call's argument list
1119
+ config.ts errors.ts logger.ts validated per-environment config, flat errors, structural logger
1120
+ decision-text.ts headers.ts how a decision reads; the cross-process attribution convention
1121
+ engine/ engine.ts decision.ts the four-operation seam; chains -> decision (pure)
1122
+ node-sdk-engine.ts the only file importing @stigg/node-server-sdk
1123
+ rest-governance-engine.ts the only file importing @stigg/typescript
1124
+ composite-engine.ts create-engine.ts routing per operation, and who picked what
1125
+ core/ run-handle.ts the handle every binding consumes
1126
+ bindings/ client/core/ the recipe contract, the registry, the wrapper
1127
+ client/providers/ client/gateways/ a model provider, or a gateway you call
1128
+ proxy/ a gateway you operate: headers in, ingest only
1129
+ shared/ usage shapes and metric tables both halves read
1130
+
1131
+ docs/ instances.md what exists at runtime, and what is shared
1132
+
1133
+ testing/ mock-stigg-server.ts the Stigg stand-in the examples and the tests share
1134
+
1135
+ examples/
1136
+ shared/ demo-env, the two mocks, setup-governance scaffolding every example uses
1137
+ client/ core/{chat,vendor} + anthropic|openai|gemini|openrouter|litellm|typesafe a helper per vendor
1138
+ two-agents two siblings, one team budget between them
1139
+ gateway/ core/{gateway,vendor,proxy} + openrouter|litellm calling one; operating one on a real socket
1140
+ ```
1141
+
1142
+ The examples mirror the bindings directory deliberately — a generic core plus a thin per-vendor
1143
+ file is the shape the problem has, at both layers.
1144
+
1145
+ Core never imports a binding or a concrete engine, and no binding imports another — only
1146
+ `stigg-agent-sdk.ts` knows both, which is what lets `wrap()` live on the SDK object
1147
+ without core learning a vendor's name. Adding a provider is a recipe file; adding an engine is a
1148
+ leaf file plus a branch in the factory.