@stackbone/sdk 0.1.0-alpha.3 → 0.1.0-alpha.4

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.
package/CHANGELOG.md CHANGED
@@ -7,8 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Removed
11
+
12
+ - **Breaking:** removed the `client.observability` surface (`spanProcessor`,
13
+ `logger`, `closeRun`, `closeLogger`, `flush`). Observability is no longer a
14
+ creator-facing `client` module: agent code logs through the handler-scoped
15
+ `ctx.logger` (already bound to the run) — plain `console.*` is captured and
16
+ correlated too — and the Studio run timeline is produced by the runtime, not
17
+ by code the agent registers. The platform primitives (`PlatformLogger`,
18
+ `RunStepsSpanProcessor`, `aggregateRunCost`) stay available on the
19
+ `@stackbone/sdk/observability` subpath, consumed by the runtime wrapper and
20
+ the agent emulator.
21
+
10
22
  ### Changed
11
23
 
24
+ - The runtime now injects the SDK client into every handler context as
25
+ `ctx.client` — one `StackboneClient` per process, shared across invocations.
26
+ Agent code reads it off the invocation context (`async run({ input, client })
27
+ { ... }`) instead of constructing one at module scope, so a module-level
28
+ `const client = createClient()` is no longer required. `createClient()` stays
29
+ exported as an escape hatch for building a client outside a handler (scripts,
30
+ tests, one-off tasks) or with explicit config overrides.
31
+
32
+ - `client.queues` is now live (was a `notImplemented` stub). `publish`,
33
+ `schedule`, `unschedule` and `listSchedules` make authenticated calls to the
34
+ control-plane BullMQ dispatcher (`/api/v1/agent/queues/*`) using the existing
35
+ `STACKBONE_AGENT_JWT` channel — the agent never touches Redis.
36
+ **Breaking:** `publish` now takes `{ name, payload, retries?, delay?,
37
+ deduplicationId? }` and returns `{ messageId }` (was `{ url, body, retries?,
38
+ delay?, deduplicationId?, headers? }`). `name` is the opaque job name (e.g.
39
+ `send-email`), `payload` the opaque body, `delay` defers delivery in ms and
40
+ `retries` overrides the platform retry default per job. New public request
41
+ types `ScheduleRequest` / `UnscheduleRequest` are exported from the barrel.
42
+ Errors surface under the new `queues_*` prefix.
43
+
12
44
  - `ResolvedConfig` is now an opaque, typed snapshot — the live `env`
13
45
  channel is gone. Every consumer used to read environment variables with
14
46
  the pattern `resolved.config.fieldX ?? resolved.env['FIELDX']`, which
package/README.md CHANGED
@@ -18,9 +18,9 @@ Official TypeScript SDK for [Stackbone](https://stackbone.ai) — the marketplac
18
18
  - **Approval** — Human-in-the-loop inbox: fire-and-forget approval requests, HMAC-signed decision callbacks, and an LLM tool wrapper that gates execution behind human review
19
19
  - **Secrets** — Read organization-encrypted secrets registered in the dashboard, no client-side cache so rotations propagate immediately
20
20
  - **Config** — Typed reads of dynamic per-agent configuration the user set in the dashboard
21
- - **Coming soon (types only)** — `queues` (cross-container HTTP push via QStash), `memory` (mem0-backed long-term memory), `prompts` (managed prompts with `{{var}}` templates), `connections` (OAuth integrations) and `events` (org-wide event bus). Public types are exported from `@stackbone/sdk` today; the runtime is not wired yet, so there is no live `client.X` accessor.
21
+ - **Coming soon (types only)** — `queues` (cross-container HTTP push via the BullMQ job dispatcher), `memory` (mem0-backed long-term memory), `prompts` (managed prompts with `{{var}}` templates), `connections` (OAuth integrations) and `events` (org-wide event bus). Public types are exported from `@stackbone/sdk` today; the runtime is not wired yet, so there is no live `client.X` accessor.
22
22
  - **TypeScript-first** — Full type definitions and a uniform `Result<T>` envelope on every method (no thrown errors at the SDK boundary)
23
- - **Lazy initialization** — Modules and partner SDKs are constructed on first access, so env vars rotated by the control plane after `createClient()` are still picked up
23
+ - **Lazy initialization** — Modules and partner SDKs are constructed on first access, so env vars rotated by the control plane after the client is built are still picked up
24
24
 
25
25
  ## Installation
26
26
 
@@ -37,26 +37,44 @@ pnpm add @stackbone/sdk
37
37
 
38
38
  > Requires Node.js 24 or newer (the runtime your agent container ships with). The SDK is server-only — it is not built for the browser.
39
39
 
40
- Need a copy-pasteable starting point? See the [`example/`](https://github.com/stackbone/stackbone/tree/main/libs/sdk/example) directory in the repository — one ESM and one CJS file showing the canonical agent shape (schema with `@stackbone/sdk/db`, `createClient`, async handler).
40
+ Need a copy-pasteable starting point? See the [`example/`](https://github.com/stackbone/stackbone/tree/main/libs/sdk/example) directory in the repository — one ESM and one CJS file showing the canonical agent shape (schema with `@stackbone/sdk/db`, the runtime-injected `ctx.client`, async handler).
41
41
 
42
42
  ## Quick Start
43
43
 
44
- ### Initialize the Client
44
+ ### The Client
45
45
 
46
- In production, every option falls back to an environment variable injected by the platform at provisioning time, so a zero-arg call is the common case:
46
+ You don't construct the client. The runtime builds a single `StackboneClient` per process — wired from the env vars the platform injects at provisioning time and hands it to every handler as `ctx.client`. Define your agent with `defineAgent` and destructure `client` (and `input`) off the invocation context:
47
47
 
48
48
  ```ts
49
- import { createClient } from '@stackbone/sdk';
50
-
51
- const stackbone = createClient();
49
+ import { defineAgent, z } from '@stackbone/sdk';
50
+
51
+ export default defineAgent({
52
+ invoke: {
53
+ input: z.object({ email: z.string() }),
54
+ output: z.object({ id: z.number() }),
55
+ async run({ input, client }) {
56
+ const [row] = await client.database
57
+ .insert(leads)
58
+ .values({ email: input.email, status: 'new', score: 0 })
59
+ .returning();
60
+ return { id: row.id };
61
+ },
62
+ },
63
+ });
52
64
  ```
53
65
 
54
- Pass overrides explicitly when you need them useful for local development against the emulator, or when running outside a Stackbone container:
66
+ #### Escape hatch`createClient()`
67
+
68
+ `createClient()` is still exported for when you need a client **outside** a handler (a script, a test, a one-off task) or want to override config explicitly — useful for local development against the emulator or when running outside a Stackbone container. In normal agent code you never need it; reach for `ctx.client` instead.
55
69
 
56
70
  ```ts
57
71
  import { createClient } from '@stackbone/sdk';
58
72
 
59
- const stackbone = createClient({
73
+ // Zero-arg: every option falls back to an injected env var.
74
+ const stackbone = createClient();
75
+
76
+ // Or pass overrides explicitly.
77
+ const local = createClient({
60
78
  agentJwt: process.env.STACKBONE_AGENT_JWT,
61
79
  stackboneApiUrl: 'http://localhost:3000',
62
80
  agentId: 'agent_local_dev',
@@ -69,7 +87,7 @@ const stackbone = createClient({
69
87
  `client.database` is a lazy [Drizzle ORM](https://orm.drizzle.team/) instance bound to the agent's Postgres connection (`STACKBONE_POSTGRES_URL`). The full Drizzle surface (`select`, `insert`, `update`, `delete`, `transaction`, `execute`, `query`, `sql\`\``) is exposed verbatim, and the column builders live behind the `@stackbone/sdk/db`subpath so the creator never has to install`drizzle-orm`, `drizzle-orm/pg-core`or`postgres` directly.
70
88
 
71
89
  ```ts
72
- import { createClient } from '@stackbone/sdk';
90
+ import { defineAgent, z } from '@stackbone/sdk';
73
91
  import { eq, integer, pgTable, text } from '@stackbone/sdk/db';
74
92
 
75
93
  // 1. Declare your tables — types flow end to end.
@@ -80,29 +98,39 @@ const leads = pgTable('leads', {
80
98
  score: integer('score').notNull(),
81
99
  });
82
100
 
83
- const stackbone = createClient();
101
+ export default defineAgent({
102
+ invoke: {
103
+ input: z.object({}),
104
+ output: z.object({}),
105
+ async run({ client }) {
106
+ // 2. Query — `data` is typed from the schema, no codegen needed.
107
+ const rows = await client.database
108
+ .select()
109
+ .from(leads)
110
+ .where(eq(leads.status, 'qualified'))
111
+ .limit(20);
84
112
 
85
- // 2. Query — `data` is typed from the schema, no codegen needed.
86
- const rows = await stackbone.database
87
- .select()
88
- .from(leads)
89
- .where(eq(leads.status, 'qualified'))
90
- .limit(20);
113
+ // 3. Mutate.
114
+ await client.database
115
+ .insert(leads)
116
+ .values({ id: 1, email: 'jane@example.com', status: 'new', score: 0 });
91
117
 
92
- // 3. Mutate.
93
- await stackbone.database
94
- .insert(leads)
95
- .values({ id: 1, email: 'jane@example.com', status: 'new', score: 0 });
118
+ await client.database.update(leads).set({ status: 'contacted' }).where(eq(leads.id, 1));
96
119
 
97
- await stackbone.database.update(leads).set({ status: 'contacted' }).where(eq(leads.id, 1));
120
+ await client.database.delete(leads).where(eq(leads.id, 1));
98
121
 
99
- await stackbone.database.delete(leads).where(eq(leads.id, 1));
122
+ return {};
123
+ },
124
+ },
125
+ });
100
126
  ```
101
127
 
128
+ The snippets below assume the `client` destructured off `ctx` inside a handler (`async run({ client }) { ... }`).
129
+
102
130
  Interactive transactions yield a `tx` with the same Drizzle surface as `client.database`:
103
131
 
104
132
  ```ts
105
- await stackbone.database.transaction(async (tx) => {
133
+ await client.database.transaction(async (tx) => {
106
134
  await tx.insert(leads).values({ id: 2, email: 'b@x.com', status: 'new', score: 0 });
107
135
  await tx.update(leads).set({ status: 'qualified' }).where(eq(leads.id, 2));
108
136
  });
@@ -113,7 +141,7 @@ For raw SQL, reach for the `sql` tag re-exported from `@stackbone/sdk/db`:
113
141
  ```ts
114
142
  import { sql } from '@stackbone/sdk/db';
115
143
 
116
- const { rows } = await stackbone.database.execute(sql`SELECT NOW()`);
144
+ const { rows } = await client.database.execute(sql`SELECT NOW()`);
117
145
  ```
118
146
 
119
147
  > If `STACKBONE_POSTGRES_URL` is unset, the first call to `client.database` raises `database_not_configured` with a hint to run `stackbone dev`. Migrations are governed by the `stackbone db migrate *` CLI commands — `client.database` itself is just the runtime query surface.
@@ -124,7 +152,7 @@ const { rows } = await stackbone.database.execute(sql`SELECT NOW()`);
124
152
 
125
153
  ```ts
126
154
  // Upload an object
127
- const { data, error } = await stackbone.storage
155
+ const { data, error } = await client.storage
128
156
  .from('uploads')
129
157
  .upload('reports/2026-04-29.pdf', pdfBlob, {
130
158
  contentType: 'application/pdf',
@@ -132,22 +160,22 @@ const { data, error } = await stackbone.storage
132
160
  });
133
161
 
134
162
  // Download an object (returns a Blob)
135
- const { data, error } = await stackbone.storage.from('uploads').download('reports/2026-04-29.pdf');
163
+ const { data, error } = await client.storage.from('uploads').download('reports/2026-04-29.pdf');
136
164
 
137
165
  // List objects (paginated)
138
- const { data, error } = await stackbone.storage
166
+ const { data, error } = await client.storage
139
167
  .from('uploads')
140
168
  .list({ prefix: 'reports/', limit: 50 });
141
169
 
142
170
  // Delete an object
143
- const { data, error } = await stackbone.storage.from('uploads').remove('reports/2026-04-29.pdf');
171
+ const { data, error } = await client.storage.from('uploads').remove('reports/2026-04-29.pdf');
144
172
  ```
145
173
 
146
174
  Signed URLs and a public URL helper are exposed for direct browser uploads / downloads:
147
175
 
148
176
  ```ts
149
177
  // Pre-signed upload URL (1h by default; clamp the contentType into the signature)
150
- const { data: upload } = await stackbone.storage
178
+ const { data: upload } = await client.storage
151
179
  .from('uploads')
152
180
  .getSignedUploadUrl('avatars/user-42.png', {
153
181
  expiresIn: 600,
@@ -156,12 +184,12 @@ const { data: upload } = await stackbone.storage
156
184
 
157
185
  // Pre-signed download URL — prefer this over `download()` for large objects,
158
186
  // since `download()` materialises the entire body in memory as a Blob.
159
- const { data: download } = await stackbone.storage
187
+ const { data: download } = await client.storage
160
188
  .from('uploads')
161
189
  .getSignedDownloadUrl('reports/2026-04-29.pdf');
162
190
 
163
191
  // Canonical public URL (only fetchable for public buckets)
164
- const { data: url } = stackbone.storage.from('uploads').getPublicUrl('avatars/user-42.png');
192
+ const { data: url } = client.storage.from('uploads').getPublicUrl('avatars/user-42.png');
165
193
  ```
166
194
 
167
195
  ### AI
@@ -170,7 +198,7 @@ const { data: url } = stackbone.storage.from('uploads').getPublicUrl('avatars/us
170
198
 
171
199
  ```ts
172
200
  // Chat completion
173
- const { data, error } = await stackbone.ai.chat.completions.create({
201
+ const { data, error } = await client.ai.chat.completions.create({
174
202
  model: 'anthropic/claude-sonnet-4.5',
175
203
  messages: [
176
204
  { role: 'system', content: 'You are a sales qualification assistant.' },
@@ -186,7 +214,7 @@ if (!error) {
186
214
  Streaming uses the same method with `stream: true`. The `Result` envelope only covers the handshake — once the stream is open, mid-flight errors propagate through the iterator, so wrap your `for await` loop in `try/catch`:
187
215
 
188
216
  ```ts
189
- const { data: stream, error } = await stackbone.ai.chat.completions.create({
217
+ const { data: stream, error } = await client.ai.chat.completions.create({
190
218
  model: 'anthropic/claude-sonnet-4.5',
191
219
  messages: [{ role: 'user', content: 'Stream me a haiku.' }],
192
220
  stream: true,
@@ -203,7 +231,7 @@ Embeddings, image generation and a model catalogue are first-class too:
203
231
 
204
232
  ```ts
205
233
  // Embeddings — returns the standard OpenAI shape
206
- const { data, error } = await stackbone.ai.embeddings.create({
234
+ const { data, error } = await client.ai.embeddings.create({
207
235
  model: 'openai/text-embedding-3-small',
208
236
  input: 'A red Spanish sword from the 11th century.',
209
237
  });
@@ -211,14 +239,14 @@ const { data, error } = await stackbone.ai.embeddings.create({
211
239
  // Image generation — OpenRouter routes image models through chat completions;
212
240
  // the SDK normalises the response so callers see the OpenAI-shaped payload.
213
241
  // Returns `ai_no_image_generated` if the model produces zero images.
214
- const { data, error } = await stackbone.ai.images.generate({
242
+ const { data, error } = await client.ai.images.generate({
215
243
  model: 'google/gemini-2.5-flash-image',
216
244
  prompt: 'A medieval Spanish sword on a velvet cushion, museum lighting',
217
245
  });
218
246
 
219
247
  // List available models — preserves OpenRouter-specific fields like
220
248
  // `pricing`, `context_length`, `supported_parameters`, `architecture`
221
- const { data, error } = await stackbone.ai.models.list();
249
+ const { data, error } = await client.ai.models.list();
222
250
  ```
223
251
 
224
252
  Cancellation works the way you'd expect — pass an `AbortSignal` and aborts surface as `ai_aborted` on non-streaming calls:
@@ -227,7 +255,7 @@ Cancellation works the way you'd expect — pass an `AbortSignal` and aborts sur
227
255
  const controller = new AbortController();
228
256
  setTimeout(() => controller.abort(), 5_000);
229
257
 
230
- const { data, error } = await stackbone.ai.chat.completions.create(
258
+ const { data, error } = await client.ai.chat.completions.create(
231
259
  { model: 'anthropic/claude-sonnet-4.5', messages: [...] },
232
260
  { signal: controller.signal },
233
261
  );
@@ -241,16 +269,16 @@ The fast path lets the SDK handle embeddings for you — pass an embedding `mode
241
269
 
242
270
  ```ts
243
271
  // 1. Parse a document → plain text. Supports text/*, text/markdown and application/pdf.
244
- const text = await stackbone.rag.parse(file);
272
+ const text = await client.rag.parse(file);
245
273
 
246
274
  // 2. Split into chunks. Pure utility, no DB call.
247
- const chunks = stackbone.rag.chunk(text, { size: 512, overlap: 64 });
275
+ const chunks = client.rag.chunk(text, { size: 512, overlap: 64 });
248
276
 
249
277
  // 3. Ingest. The SDK embeds the chunks for you in batches of 128 and
250
278
  // provisions the schema on the first call (HNSW + cosine, dims inferred
251
279
  // from the first batch). Re-ingesting the same `id` atomically replaces
252
280
  // all of that document's chunks.
253
- await stackbone.rag.ingest({
281
+ await client.rag.ingest({
254
282
  id: 'doc-1',
255
283
  chunks,
256
284
  model: 'openai/text-embedding-3-small',
@@ -259,7 +287,7 @@ await stackbone.rag.ingest({
259
287
 
260
288
  // 4. Retrieve. Pass the user's question and the same model — the SDK embeds
261
289
  // it for you and returns hits scored in [0, 1] (cosine similarity).
262
- const { data: hits } = await stackbone.rag.retrieve({
290
+ const { data: hits } = await client.rag.retrieve({
263
291
  text: 'how does X work?',
264
292
  model: 'openai/text-embedding-3-small',
265
293
  topK: 5,
@@ -270,12 +298,12 @@ const { data: hits } = await stackbone.rag.retrieve({
270
298
  The escape hatch — pass embeddings precomputed yourself (different provider, custom dimensions, your own batching, deterministic offline tests):
271
299
 
272
300
  ```ts
273
- const { data: embeddings } = await stackbone.ai.embeddings.create({
301
+ const { data: embeddings } = await client.ai.embeddings.create({
274
302
  model: 'openai/text-embedding-3-small',
275
303
  input: chunks,
276
304
  });
277
305
 
278
- await stackbone.rag.ingest({
306
+ await client.rag.ingest({
279
307
  id: 'doc-1',
280
308
  chunks: chunks.map((content, i) => ({
281
309
  content,
@@ -284,7 +312,7 @@ await stackbone.rag.ingest({
284
312
  metadata: { source: 'manual-upload' },
285
313
  });
286
314
 
287
- const { data: hits } = await stackbone.rag.retrieve({
315
+ const { data: hits } = await client.rag.retrieve({
288
316
  embedding: queryEmbedding,
289
317
  topK: 5,
290
318
  });
@@ -293,29 +321,29 @@ const { data: hits } = await stackbone.rag.retrieve({
293
321
  Delete by id (whole document) or by metadata predicate:
294
322
 
295
323
  ```ts
296
- await stackbone.rag.delete('doc-1');
297
- await stackbone.rag.delete(['doc-1', 'doc-2']);
324
+ await client.rag.delete('doc-1');
325
+ await client.rag.delete(['doc-1', 'doc-2']);
298
326
 
299
- await stackbone.rag.deleteWhere({ source: 'manual-upload' });
327
+ await client.rag.deleteWhere({ source: 'manual-upload' });
300
328
  ```
301
329
 
302
330
  Logical separation between document sets is via an optional `namespace` (default `'default'`). Backed by an indexed column in the same table — no extra physical collections to manage:
303
331
 
304
332
  ```ts
305
- await stackbone.rag.ingest({
333
+ await client.rag.ingest({
306
334
  id: 'faq-1',
307
335
  chunks: [...],
308
336
  namespace: 'faqs',
309
337
  });
310
338
 
311
- await stackbone.rag.retrieve({ embedding, namespace: 'faqs' });
312
- await stackbone.rag.deleteWhere({}, { namespace: 'faqs' });
339
+ await client.rag.retrieve({ embedding, namespace: 'faqs' });
340
+ await client.rag.deleteWhere({}, { namespace: 'faqs' });
313
341
  ```
314
342
 
315
343
  If you switch embedding models (e.g. `text-embedding-3-small` → `text-embedding-3-large`) the dimensions will not match what the table was provisioned with. The next `ingest()` returns `rag_dim_mismatch` with the exact stored vs received dims. Recreate the schema with:
316
344
 
317
345
  ```ts
318
- await stackbone.rag.reset();
346
+ await client.rag.reset();
319
347
  ```
320
348
 
321
349
  > `parse()` is intentionally minimal: passthrough for text and markdown, `unpdf` for text-only PDFs (no OCR, no layout reconstruction). For complex documents (scanned PDFs, Office files, multi-column layouts) the recommended path is to parse upstream of the SDK and pass the resulting text into `chunk()` directly.
@@ -328,7 +356,7 @@ This shape decouples waiting from the container lifecycle — the agent does not
328
356
 
329
357
  ```ts
330
358
  // 1. Issue an approval — returns immediately, the human reviews asynchronously.
331
- const { data, error } = await stackbone.airoval.request({
359
+ const { data, error } = await client.airoval.request({
332
360
  topic: 'lead.qualify',
333
361
  payload: { leadId: 42, score: 0.6 },
334
362
  title: 'Approve lead qualification',
@@ -346,7 +374,7 @@ const { data, error } = await stackbone.airoval.request({
346
374
  // returns a typed Decision<T>. Pass the raw `Request` (Fetch / Hono /
347
375
  // undici) and switch on `decision.status`.
348
376
  app.post('/approvals/lead-qualify', async (c) => {
349
- const result = await stackbone.airoval.verify<{ leadId: number; score: number }>(c.req.raw);
377
+ const result = await client.airoval.verify<{ leadId: number; score: number }>(c.req.raw);
350
378
  if (result.error) return c.text('invalid', 401);
351
379
  switch (result.data.status) {
352
380
  case 'approved':
@@ -367,9 +395,9 @@ app.post('/approvals/lead-qualify', async (c) => {
367
395
  Operations on existing approvals:
368
396
 
369
397
  ```ts
370
- await stackbone.airoval.cancel(approvalId, 'no longer needed');
371
- const { data: record } = await stackbone.airoval.get<LeadPayload>(approvalId);
372
- const { data: page } = await stackbone.airoval.list({
398
+ await client.airoval.cancel(approvalId, 'no longer needed');
399
+ const { data: record } = await client.airoval.get<LeadPayload>(approvalId);
400
+ const { data: page } = await client.airoval.list({
373
401
  status: 'pending',
374
402
  topic: 'lead.qualify',
375
403
  limit: 50,
@@ -378,10 +406,10 @@ const { data: page } = await stackbone.airoval.list({
378
406
 
379
407
  #### Approval-gated LLM tools
380
408
 
381
- `stackbone.airoval.tool()` wraps an LLM tool so the agent loop pauses for human review before `execute()` runs. When `needsApproval` returns `true`, `invoke()` opens an approval and returns a `pending` marker — the caller persists agent state and breaks the loop; the eventual decision callback (handled via `verify()`) is the signal to resume.
409
+ `client.airoval.tool()` wraps an LLM tool so the agent loop pauses for human review before `execute()` runs. When `needsApproval` returns `true`, `invoke()` opens an approval and returns a `pending` marker — the caller persists agent state and breaks the loop; the eventual decision callback (handled via `verify()`) is the signal to resume.
382
410
 
383
411
  ```ts
384
- const sendInvoice = stackbone.airoval.tool({
412
+ const sendInvoice = client.airoval.tool({
385
413
  name: 'send_invoice',
386
414
  description: 'Send an invoice to a recipient.',
387
415
  parameters: {
@@ -402,7 +430,7 @@ const sendInvoice = stackbone.airoval.tool({
402
430
  });
403
431
 
404
432
  // Pass the tool to chat.completions.create — it speaks the OpenAI shape.
405
- const completion = await stackbone.ai.chat.completions.create({
433
+ const completion = await client.ai.chat.completions.create({
406
434
  model: 'anthropic/claude-sonnet-4.5',
407
435
  messages,
408
436
  tools: [sendInvoice.openaiSpec()],
@@ -433,13 +461,13 @@ The signing key for `verify()` is read from `STACKBONE_APPROVAL_SIGNING_KEY` (or
433
461
 
434
462
  ```ts
435
463
  // Single secret — `secrets_not_found` error if the name is not registered.
436
- const { data: apiKey, error } = await stackbone.secrets.get('STRIPE_API_KEY');
464
+ const { data: apiKey, error } = await client.secrets.get('STRIPE_API_KEY');
437
465
  if (!error) {
438
466
  const stripe = new Stripe(apiKey);
439
467
  }
440
468
 
441
469
  // Bulk read at boot — one round-trip; missing names come back as omissions.
442
- const { data: secrets } = await stackbone.secrets.getMany(['STRIPE_API_KEY', 'SLACK_BOT_TOKEN']);
470
+ const { data: secrets } = await client.secrets.getMany(['STRIPE_API_KEY', 'SLACK_BOT_TOKEN']);
443
471
  // secrets: { STRIPE_API_KEY?: string; SLACK_BOT_TOKEN?: string }
444
472
  ```
445
473
 
@@ -449,17 +477,17 @@ const { data: secrets } = await stackbone.secrets.getMany(['STRIPE_API_KEY', 'SL
449
477
 
450
478
  ```ts
451
479
  // Typed read — the generic narrows `data` to `'formal' | 'casual'`.
452
- const { data: tone } = await stackbone.config.get<'formal' | 'casual'>('reply_tone');
480
+ const { data: tone } = await client.config.get<'formal' | 'casual'>('reply_tone');
453
481
 
454
482
  // Complex JSON — pass the full shape as the generic.
455
483
  interface Preferences {
456
484
  tone: 'formal' | 'casual';
457
485
  maxEmailsPerDay: number;
458
486
  }
459
- const { data: prefs } = await stackbone.config.get<Preferences>('preferences');
487
+ const { data: prefs } = await client.config.get<Preferences>('preferences');
460
488
 
461
489
  // Bulk read — typed via the second generic; missing keys come back as omissions.
462
- const { data: cfg } = await stackbone.config.getMany<{
490
+ const { data: cfg } = await client.config.getMany<{
463
491
  reply_tone: 'formal' | 'casual';
464
492
  max_emails: number;
465
493
  }>(['reply_tone', 'max_emails']);
@@ -489,7 +517,7 @@ The pending surfaces are `queues` (cross-container HTTP push), `memory` (mem0-ba
489
517
 
490
518
  ## Configuration
491
519
 
492
- `createClient` accepts a single configuration object. Every field is optional and falls back to an environment variable, which the platform injects into the agent container at boot:
520
+ The runtime-injected `ctx.client` is already configured from the env vars the platform injects, so agent code rarely touches these fields. They matter when you reach for the `createClient()` escape hatch: `createClient` accepts a single configuration object where every field is optional and falls back to an environment variable, which the platform injects into the agent container at boot:
493
521
 
494
522
  ```ts
495
523
  const stackbone = createClient({
@@ -505,8 +533,6 @@ const stackbone = createClient({
505
533
  // OpenRouter credentials → OPENROUTER_API_KEY / OPENROUTER_BASE_URL
506
534
  openrouterKey: '...',
507
535
  openrouterBaseUrl: 'https://openrouter.ai/api/v1',
508
- // QStash credentials (queues) → QSTASH_TOKEN / QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY
509
- qstashToken: '...',
510
536
  // RAG document parser → LLAMA_PARSE_API_KEY
511
537
  llamaParseApiKey: '...',
512
538
  // mem0 credentials (memory module) → MEM0_API_KEY / MEM0_BASE_URL
@@ -530,7 +556,7 @@ const stackbone = createClient({
530
556
  });
531
557
  ```
532
558
 
533
- Inside a Stackbone-hosted container all of these are pre-populated, so production agent code is just `createClient()`. For local development the `stackbone dev` emulator wires up the same env vars against MinIO, a local Postgres branch and your own OpenRouter key.
559
+ Inside a Stackbone-hosted container all of these are pre-populated, so production agent code just reads `ctx.client` (a zero-arg `createClient()` would resolve the same values). For local development the `stackbone dev` emulator wires up the same env vars against MinIO, a local Postgres branch and your own OpenRouter key.
534
560
 
535
561
  ## Result Envelope and Error Handling
536
562
 
@@ -550,7 +576,7 @@ interface SdkError {
550
576
  Narrowing on `error` automatically refines `data` to `T`, so the typical caller looks like:
551
577
 
552
578
  ```ts
553
- const result = await stackbone.ai.chat.completions.create({ model, messages });
579
+ const result = await client.ai.chat.completions.create({ model, messages });
554
580
 
555
581
  if (result.error) {
556
582
  console.error(`[${result.error.code}] ${result.error.message}`, result.error.meta);
@@ -562,22 +588,22 @@ console.log(result.data.choices[0]?.message.content);
562
588
 
563
589
  Each module ships its own stable code prefix so you can pattern-match without parsing the message:
564
590
 
565
- | Prefix | Source | Examples |
566
- | ----------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
567
- | `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated` |
568
- | `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error` |
569
- | `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error` |
570
- | `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed`, `approval_unauthorized`, `approval_forbidden`, `approval_not_found`, `approval_rate_limited`, `approval_unavailable` |
571
- | `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response`, `secrets_unauthorized`, `secrets_forbidden`, `secrets_rate_limited`, `secrets_unavailable` |
572
- | `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response`, `config_unauthorized`, `config_forbidden`, `config_rate_limited`, `config_unavailable` |
573
- | `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented` |
574
- | `http_*` | transport-level errors | `http_timeout`, `http_aborted`, `http_network_error`, `http_parse_error`, `http_request_failed` — surface-level statuses (401/403/404/429/5xx) remap to `<surface>_*` codes |
575
- | `database_*` | `client.database` | `database_not_configured` (raised when `STACKBONE_POSTGRES_URL` is unset) |
576
- | `observability_*` | `client.observability` | `observability_close_run_failed` |
577
- | `contract_*` | gated surfaces | `contract_malformed`, `contract_unreachable`, `contract_version_unsupported` — emitted by any gated call when the contract handshake fails |
578
- | `capability_*` | gated surfaces | `capability_unavailable` — emitted when the negotiated contract advertises no support for the surface |
579
- | `*_missing` | configuration | `agent_id_missing`, `openrouter_key_missing`, `database_url_missing`, `stackbone_api_url_missing` |
580
- | `not_implemented` | stubbed module surface | returned by every "coming soon" method until it ships |
591
+ | Prefix | Source | Examples |
592
+ | ----------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
593
+ | `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated` |
594
+ | `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error` |
595
+ | `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error` |
596
+ | `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed`, `approval_unauthorized`, `approval_forbidden`, `approval_not_found`, `approval_rate_limited`, `approval_unavailable` |
597
+ | `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response`, `secrets_unauthorized`, `secrets_forbidden`, `secrets_rate_limited`, `secrets_unavailable` |
598
+ | `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response`, `config_unauthorized`, `config_forbidden`, `config_rate_limited`, `config_unavailable` |
599
+ | `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented` |
600
+ | `http_*` | transport-level errors | `http_timeout`, `http_aborted`, `http_network_error`, `http_parse_error`, `http_request_failed` — surface-level statuses (401/403/404/429/5xx) remap to `<surface>_*` codes |
601
+ | `database_*` | `client.database` | `database_not_configured` (raised when `STACKBONE_POSTGRES_URL` is unset) |
602
+ | `observability_*` | run-cost rollup (`@stackbone/sdk/observability`, runtime-wired) | `observability_close_run_failed` |
603
+ | `contract_*` | gated surfaces | `contract_malformed`, `contract_unreachable`, `contract_version_unsupported` — emitted by any gated call when the contract handshake fails |
604
+ | `capability_*` | gated surfaces | `capability_unavailable` — emitted when the negotiated contract advertises no support for the surface |
605
+ | `*_missing` | configuration | `agent_id_missing`, `openrouter_key_missing`, `database_url_missing`, `stackbone_api_url_missing` |
606
+ | `not_implemented` | stubbed module surface | returned by every "coming soon" method until it ships |
581
607
 
582
608
  > The canonical inventory of every code lives in `src/errors/codes.ts` as a typed catalog (`SdkErrorCode`). The table above is the human-readable summary; adding or removing a code is a single-file edit there and the compiler refuses any literal `code` value not declared in the catalog. Pattern-match against `SdkErrorCode` (exported from `@stackbone/sdk`) for full type narrowing, or call `isSdkErrorCode(raw)` to widen a wire string back into the catalog.
583
609
 
package/db/index.d.cts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from 'drizzle-orm';
2
2
  export * from 'drizzle-orm/pg-core';
3
3
  export { ColumnsWithTable, SelectedFields, SelectedFieldsFlat, SelectedFieldsOrdered, TableConfig } from 'drizzle-orm/pg-core';
4
+ import '@stackbone/rag-core/schema';
package/db/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from 'drizzle-orm';
2
2
  export * from 'drizzle-orm/pg-core';
3
3
  export { ColumnsWithTable, SelectedFields, SelectedFieldsFlat, SelectedFieldsOrdered, TableConfig } from 'drizzle-orm/pg-core';
4
+ import '@stackbone/rag-core/schema';