@stackbone/sdk 0.1.0-alpha.2 → 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/README.md CHANGED
@@ -13,17 +13,14 @@ Official TypeScript SDK for [Stackbone](https://stackbone.ai) — the marketplac
13
13
  - **Database** — Direct Postgres access through a lazy Drizzle ORM wrapper bound to the agent's connection string, with full type-safe queries, transactions, and `sql` template literals — Drizzle is re-exported via `@stackbone/sdk/db` so the agent's `package.json` only depends on `@stackbone/sdk`
14
14
  - **Storage** — S3-compatible object storage (Cloudflare R2 in prod, MinIO in dev) with automatic per-agent key prefixing and signed URLs
15
15
  - **AI** — Wrapper around the official `openai` SDK pointed at OpenRouter, so 300+ chat, embedding and image models are reachable through a single OpenAI-compatible API
16
- - **Queues** _(coming soon)_ — Cross-container HTTP push via QStash, with typed job payloads through module augmentation
17
16
  - **RAG** — Document parsing, chunking and `pgvector`-backed retrieval through a flat, ceremony-free API
18
- - **Memory** _(surface defined; runtime pending)_ — Long-term agent memory backed by mem0: ingest text or whole conversations, semantic search, per-user / per-session / per-agent scoping, GDPR delete-all, audit history and session lifecycle hooks
19
- - **Prompts** _(surface defined; runtime pending)_ — Managed prompts stored in the Stackbone control plane: fetch by name with optional version pinning, compile Mustache `{{var}}` templates against a variables map, and full CRUD so prompts can evolve without rebuilding the container
20
17
  - **Observability** — OpenTelemetry span processor + run-cost aggregator + per-run logger
21
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
22
19
  - **Secrets** — Read organization-encrypted secrets registered in the dashboard, no client-side cache so rotations propagate immediately
23
20
  - **Config** — Typed reads of dynamic per-agent configuration the user set in the dashboard
24
- - **Connections / Events** — OAuth integrations (Notion, GDrive, Slack…) and an organization event bus
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.
25
22
  - **TypeScript-first** — Full type definitions and a uniform `Result<T>` envelope on every method (no thrown errors at the SDK boundary)
26
- - **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
27
24
 
28
25
  ## Installation
29
26
 
@@ -40,26 +37,44 @@ pnpm add @stackbone/sdk
40
37
 
41
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.
42
39
 
43
- 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).
44
41
 
45
42
  ## Quick Start
46
43
 
47
- ### Initialize the Client
44
+ ### The Client
48
45
 
49
- 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:
50
47
 
51
48
  ```ts
52
- import { createClient } from '@stackbone/sdk';
53
-
54
- 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
+ });
55
64
  ```
56
65
 
57
- 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.
58
69
 
59
70
  ```ts
60
71
  import { createClient } from '@stackbone/sdk';
61
72
 
62
- 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({
63
78
  agentJwt: process.env.STACKBONE_AGENT_JWT,
64
79
  stackboneApiUrl: 'http://localhost:3000',
65
80
  agentId: 'agent_local_dev',
@@ -72,7 +87,7 @@ const stackbone = createClient({
72
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.
73
88
 
74
89
  ```ts
75
- import { createClient } from '@stackbone/sdk';
90
+ import { defineAgent, z } from '@stackbone/sdk';
76
91
  import { eq, integer, pgTable, text } from '@stackbone/sdk/db';
77
92
 
78
93
  // 1. Declare your tables — types flow end to end.
@@ -83,29 +98,39 @@ const leads = pgTable('leads', {
83
98
  score: integer('score').notNull(),
84
99
  });
85
100
 
86
- 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);
87
112
 
88
- // 2. Query — `data` is typed from the schema, no codegen needed.
89
- const rows = await stackbone.database
90
- .select()
91
- .from(leads)
92
- .where(eq(leads.status, 'qualified'))
93
- .limit(20);
113
+ // 3. Mutate.
114
+ await client.database
115
+ .insert(leads)
116
+ .values({ id: 1, email: 'jane@example.com', status: 'new', score: 0 });
94
117
 
95
- // 3. Mutate.
96
- await stackbone.database
97
- .insert(leads)
98
- .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));
99
119
 
100
- await stackbone.database.update(leads).set({ status: 'contacted' }).where(eq(leads.id, 1));
120
+ await client.database.delete(leads).where(eq(leads.id, 1));
101
121
 
102
- await stackbone.database.delete(leads).where(eq(leads.id, 1));
122
+ return {};
123
+ },
124
+ },
125
+ });
103
126
  ```
104
127
 
128
+ The snippets below assume the `client` destructured off `ctx` inside a handler (`async run({ client }) { ... }`).
129
+
105
130
  Interactive transactions yield a `tx` with the same Drizzle surface as `client.database`:
106
131
 
107
132
  ```ts
108
- await stackbone.database.transaction(async (tx) => {
133
+ await client.database.transaction(async (tx) => {
109
134
  await tx.insert(leads).values({ id: 2, email: 'b@x.com', status: 'new', score: 0 });
110
135
  await tx.update(leads).set({ status: 'qualified' }).where(eq(leads.id, 2));
111
136
  });
@@ -116,7 +141,7 @@ For raw SQL, reach for the `sql` tag re-exported from `@stackbone/sdk/db`:
116
141
  ```ts
117
142
  import { sql } from '@stackbone/sdk/db';
118
143
 
119
- const { rows } = await stackbone.database.execute(sql`SELECT NOW()`);
144
+ const { rows } = await client.database.execute(sql`SELECT NOW()`);
120
145
  ```
121
146
 
122
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.
@@ -127,7 +152,7 @@ const { rows } = await stackbone.database.execute(sql`SELECT NOW()`);
127
152
 
128
153
  ```ts
129
154
  // Upload an object
130
- const { data, error } = await stackbone.storage
155
+ const { data, error } = await client.storage
131
156
  .from('uploads')
132
157
  .upload('reports/2026-04-29.pdf', pdfBlob, {
133
158
  contentType: 'application/pdf',
@@ -135,22 +160,22 @@ const { data, error } = await stackbone.storage
135
160
  });
136
161
 
137
162
  // Download an object (returns a Blob)
138
- 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');
139
164
 
140
165
  // List objects (paginated)
141
- const { data, error } = await stackbone.storage
166
+ const { data, error } = await client.storage
142
167
  .from('uploads')
143
168
  .list({ prefix: 'reports/', limit: 50 });
144
169
 
145
170
  // Delete an object
146
- 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');
147
172
  ```
148
173
 
149
174
  Signed URLs and a public URL helper are exposed for direct browser uploads / downloads:
150
175
 
151
176
  ```ts
152
177
  // Pre-signed upload URL (1h by default; clamp the contentType into the signature)
153
- const { data: upload } = await stackbone.storage
178
+ const { data: upload } = await client.storage
154
179
  .from('uploads')
155
180
  .getSignedUploadUrl('avatars/user-42.png', {
156
181
  expiresIn: 600,
@@ -159,12 +184,12 @@ const { data: upload } = await stackbone.storage
159
184
 
160
185
  // Pre-signed download URL — prefer this over `download()` for large objects,
161
186
  // since `download()` materialises the entire body in memory as a Blob.
162
- const { data: download } = await stackbone.storage
187
+ const { data: download } = await client.storage
163
188
  .from('uploads')
164
189
  .getSignedDownloadUrl('reports/2026-04-29.pdf');
165
190
 
166
191
  // Canonical public URL (only fetchable for public buckets)
167
- 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');
168
193
  ```
169
194
 
170
195
  ### AI
@@ -173,7 +198,7 @@ const { data: url } = stackbone.storage.from('uploads').getPublicUrl('avatars/us
173
198
 
174
199
  ```ts
175
200
  // Chat completion
176
- const { data, error } = await stackbone.ai.chat.completions.create({
201
+ const { data, error } = await client.ai.chat.completions.create({
177
202
  model: 'anthropic/claude-sonnet-4.5',
178
203
  messages: [
179
204
  { role: 'system', content: 'You are a sales qualification assistant.' },
@@ -189,7 +214,7 @@ if (!error) {
189
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`:
190
215
 
191
216
  ```ts
192
- const { data: stream, error } = await stackbone.ai.chat.completions.create({
217
+ const { data: stream, error } = await client.ai.chat.completions.create({
193
218
  model: 'anthropic/claude-sonnet-4.5',
194
219
  messages: [{ role: 'user', content: 'Stream me a haiku.' }],
195
220
  stream: true,
@@ -206,7 +231,7 @@ Embeddings, image generation and a model catalogue are first-class too:
206
231
 
207
232
  ```ts
208
233
  // Embeddings — returns the standard OpenAI shape
209
- const { data, error } = await stackbone.ai.embeddings.create({
234
+ const { data, error } = await client.ai.embeddings.create({
210
235
  model: 'openai/text-embedding-3-small',
211
236
  input: 'A red Spanish sword from the 11th century.',
212
237
  });
@@ -214,14 +239,14 @@ const { data, error } = await stackbone.ai.embeddings.create({
214
239
  // Image generation — OpenRouter routes image models through chat completions;
215
240
  // the SDK normalises the response so callers see the OpenAI-shaped payload.
216
241
  // Returns `ai_no_image_generated` if the model produces zero images.
217
- const { data, error } = await stackbone.ai.images.generate({
242
+ const { data, error } = await client.ai.images.generate({
218
243
  model: 'google/gemini-2.5-flash-image',
219
244
  prompt: 'A medieval Spanish sword on a velvet cushion, museum lighting',
220
245
  });
221
246
 
222
247
  // List available models — preserves OpenRouter-specific fields like
223
248
  // `pricing`, `context_length`, `supported_parameters`, `architecture`
224
- const { data, error } = await stackbone.ai.models.list();
249
+ const { data, error } = await client.ai.models.list();
225
250
  ```
226
251
 
227
252
  Cancellation works the way you'd expect — pass an `AbortSignal` and aborts surface as `ai_aborted` on non-streaming calls:
@@ -230,7 +255,7 @@ Cancellation works the way you'd expect — pass an `AbortSignal` and aborts sur
230
255
  const controller = new AbortController();
231
256
  setTimeout(() => controller.abort(), 5_000);
232
257
 
233
- const { data, error } = await stackbone.ai.chat.completions.create(
258
+ const { data, error } = await client.ai.chat.completions.create(
234
259
  { model: 'anthropic/claude-sonnet-4.5', messages: [...] },
235
260
  { signal: controller.signal },
236
261
  );
@@ -244,16 +269,16 @@ The fast path lets the SDK handle embeddings for you — pass an embedding `mode
244
269
 
245
270
  ```ts
246
271
  // 1. Parse a document → plain text. Supports text/*, text/markdown and application/pdf.
247
- const text = await stackbone.rag.parse(file);
272
+ const text = await client.rag.parse(file);
248
273
 
249
274
  // 2. Split into chunks. Pure utility, no DB call.
250
- const chunks = stackbone.rag.chunk(text, { size: 512, overlap: 64 });
275
+ const chunks = client.rag.chunk(text, { size: 512, overlap: 64 });
251
276
 
252
277
  // 3. Ingest. The SDK embeds the chunks for you in batches of 128 and
253
278
  // provisions the schema on the first call (HNSW + cosine, dims inferred
254
279
  // from the first batch). Re-ingesting the same `id` atomically replaces
255
280
  // all of that document's chunks.
256
- await stackbone.rag.ingest({
281
+ await client.rag.ingest({
257
282
  id: 'doc-1',
258
283
  chunks,
259
284
  model: 'openai/text-embedding-3-small',
@@ -262,7 +287,7 @@ await stackbone.rag.ingest({
262
287
 
263
288
  // 4. Retrieve. Pass the user's question and the same model — the SDK embeds
264
289
  // it for you and returns hits scored in [0, 1] (cosine similarity).
265
- const { data: hits } = await stackbone.rag.retrieve({
290
+ const { data: hits } = await client.rag.retrieve({
266
291
  text: 'how does X work?',
267
292
  model: 'openai/text-embedding-3-small',
268
293
  topK: 5,
@@ -273,12 +298,12 @@ const { data: hits } = await stackbone.rag.retrieve({
273
298
  The escape hatch — pass embeddings precomputed yourself (different provider, custom dimensions, your own batching, deterministic offline tests):
274
299
 
275
300
  ```ts
276
- const { data: embeddings } = await stackbone.ai.embeddings.create({
301
+ const { data: embeddings } = await client.ai.embeddings.create({
277
302
  model: 'openai/text-embedding-3-small',
278
303
  input: chunks,
279
304
  });
280
305
 
281
- await stackbone.rag.ingest({
306
+ await client.rag.ingest({
282
307
  id: 'doc-1',
283
308
  chunks: chunks.map((content, i) => ({
284
309
  content,
@@ -287,7 +312,7 @@ await stackbone.rag.ingest({
287
312
  metadata: { source: 'manual-upload' },
288
313
  });
289
314
 
290
- const { data: hits } = await stackbone.rag.retrieve({
315
+ const { data: hits } = await client.rag.retrieve({
291
316
  embedding: queryEmbedding,
292
317
  topK: 5,
293
318
  });
@@ -296,29 +321,29 @@ const { data: hits } = await stackbone.rag.retrieve({
296
321
  Delete by id (whole document) or by metadata predicate:
297
322
 
298
323
  ```ts
299
- await stackbone.rag.delete('doc-1');
300
- await stackbone.rag.delete(['doc-1', 'doc-2']);
324
+ await client.rag.delete('doc-1');
325
+ await client.rag.delete(['doc-1', 'doc-2']);
301
326
 
302
- await stackbone.rag.deleteWhere({ source: 'manual-upload' });
327
+ await client.rag.deleteWhere({ source: 'manual-upload' });
303
328
  ```
304
329
 
305
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:
306
331
 
307
332
  ```ts
308
- await stackbone.rag.ingest({
333
+ await client.rag.ingest({
309
334
  id: 'faq-1',
310
335
  chunks: [...],
311
336
  namespace: 'faqs',
312
337
  });
313
338
 
314
- await stackbone.rag.retrieve({ embedding, namespace: 'faqs' });
315
- await stackbone.rag.deleteWhere({}, { namespace: 'faqs' });
339
+ await client.rag.retrieve({ embedding, namespace: 'faqs' });
340
+ await client.rag.deleteWhere({}, { namespace: 'faqs' });
316
341
  ```
317
342
 
318
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:
319
344
 
320
345
  ```ts
321
- await stackbone.rag.reset();
346
+ await client.rag.reset();
322
347
  ```
323
348
 
324
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.
@@ -331,7 +356,7 @@ This shape decouples waiting from the container lifecycle — the agent does not
331
356
 
332
357
  ```ts
333
358
  // 1. Issue an approval — returns immediately, the human reviews asynchronously.
334
- const { data, error } = await stackbone.airoval.request({
359
+ const { data, error } = await client.airoval.request({
335
360
  topic: 'lead.qualify',
336
361
  payload: { leadId: 42, score: 0.6 },
337
362
  title: 'Approve lead qualification',
@@ -349,7 +374,7 @@ const { data, error } = await stackbone.airoval.request({
349
374
  // returns a typed Decision<T>. Pass the raw `Request` (Fetch / Hono /
350
375
  // undici) and switch on `decision.status`.
351
376
  app.post('/approvals/lead-qualify', async (c) => {
352
- 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);
353
378
  if (result.error) return c.text('invalid', 401);
354
379
  switch (result.data.status) {
355
380
  case 'approved':
@@ -370,9 +395,9 @@ app.post('/approvals/lead-qualify', async (c) => {
370
395
  Operations on existing approvals:
371
396
 
372
397
  ```ts
373
- await stackbone.airoval.cancel(approvalId, 'no longer needed');
374
- const { data: record } = await stackbone.airoval.get<LeadPayload>(approvalId);
375
- 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({
376
401
  status: 'pending',
377
402
  topic: 'lead.qualify',
378
403
  limit: 50,
@@ -381,10 +406,10 @@ const { data: page } = await stackbone.airoval.list({
381
406
 
382
407
  #### Approval-gated LLM tools
383
408
 
384
- `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.
385
410
 
386
411
  ```ts
387
- const sendInvoice = stackbone.airoval.tool({
412
+ const sendInvoice = client.airoval.tool({
388
413
  name: 'send_invoice',
389
414
  description: 'Send an invoice to a recipient.',
390
415
  parameters: {
@@ -405,7 +430,7 @@ const sendInvoice = stackbone.airoval.tool({
405
430
  });
406
431
 
407
432
  // Pass the tool to chat.completions.create — it speaks the OpenAI shape.
408
- const completion = await stackbone.ai.chat.completions.create({
433
+ const completion = await client.ai.chat.completions.create({
409
434
  model: 'anthropic/claude-sonnet-4.5',
410
435
  messages,
411
436
  tools: [sendInvoice.openaiSpec()],
@@ -436,13 +461,13 @@ The signing key for `verify()` is read from `STACKBONE_APPROVAL_SIGNING_KEY` (or
436
461
 
437
462
  ```ts
438
463
  // Single secret — `secrets_not_found` error if the name is not registered.
439
- const { data: apiKey, error } = await stackbone.secrets.get('STRIPE_API_KEY');
464
+ const { data: apiKey, error } = await client.secrets.get('STRIPE_API_KEY');
440
465
  if (!error) {
441
466
  const stripe = new Stripe(apiKey);
442
467
  }
443
468
 
444
469
  // Bulk read at boot — one round-trip; missing names come back as omissions.
445
- 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']);
446
471
  // secrets: { STRIPE_API_KEY?: string; SLACK_BOT_TOKEN?: string }
447
472
  ```
448
473
 
@@ -452,17 +477,17 @@ const { data: secrets } = await stackbone.secrets.getMany(['STRIPE_API_KEY', 'SL
452
477
 
453
478
  ```ts
454
479
  // Typed read — the generic narrows `data` to `'formal' | 'casual'`.
455
- const { data: tone } = await stackbone.config.get<'formal' | 'casual'>('reply_tone');
480
+ const { data: tone } = await client.config.get<'formal' | 'casual'>('reply_tone');
456
481
 
457
482
  // Complex JSON — pass the full shape as the generic.
458
483
  interface Preferences {
459
484
  tone: 'formal' | 'casual';
460
485
  maxEmailsPerDay: number;
461
486
  }
462
- const { data: prefs } = await stackbone.config.get<Preferences>('preferences');
487
+ const { data: prefs } = await client.config.get<Preferences>('preferences');
463
488
 
464
489
  // Bulk read — typed via the second generic; missing keys come back as omissions.
465
- const { data: cfg } = await stackbone.config.getMany<{
490
+ const { data: cfg } = await client.config.getMany<{
466
491
  reply_tone: 'formal' | 'casual';
467
492
  max_emails: number;
468
493
  }>(['reply_tone', 'max_emails']);
@@ -470,98 +495,29 @@ const { data: cfg } = await stackbone.config.getMany<{
470
495
 
471
496
  `secrets.get` returns `secrets_not_found` (with the name in `meta`) when the secret is missing; `config.get` returns `config_not_found` symmetrically. Both `getMany` calls return whatever subset the organization has registered — no error for missing entries.
472
497
 
473
- ### Memory
474
-
475
- `client.memory` is the agent's long-term memory surface. The first iteration is a **scaffolding placeholder** — every method returns `not_implemented`. The public types and signatures defined here are stable and the contract callers can already type their integrations against. The real backend will be [mem0](https://mem0.ai) (`mem0ApiKey` / `MEM0_API_KEY`).
476
-
477
- Memories are scoped along three axes:
478
-
479
- - `'user'` — long-term, persists across sessions of a given end user. Default.
480
- - `'session'` — short-lived; collapsed (or dropped) when `endSession()` runs.
481
- - `'agent'` — shared across every user of the agent (preferences, global knowledge).
482
-
483
- ```ts
484
- // Ingest a fact (raw text or an OpenAI-shaped conversation).
485
- const { data: memory } = await stackbone.memory.add(
486
- 'The user prefers replies in Spanish and addresses them by their last name.',
487
- { userId: 'user_42', scope: 'user', metadata: { source: 'onboarding' } },
488
- );
489
-
490
- // Or ingest a whole conversation — the backend summarises it into facts.
491
- await stackbone.memory.add(
492
- [
493
- { role: 'user', content: 'Call me Mr. Torres please.' },
494
- { role: 'assistant', content: 'Got it, Mr. Torres.' },
495
- ],
496
- { userId: 'user_42', sessionId: 'sess_abc', scope: 'session' },
497
- );
498
-
499
- // Semantic search across the user's memories.
500
- const { data: hits } = await stackbone.memory.search('how should I address this user?', {
501
- userId: 'user_42',
502
- limit: 5,
503
- threshold: 0.7,
504
- includeScopes: ['user', 'agent'],
505
- });
506
- ```
507
-
508
- Management — read, paginate, edit and audit a single fact:
509
-
510
- ```ts
511
- const { data: memory } = await stackbone.memory.get(memoryId);
512
-
513
- const { data: page } = await stackbone.memory.list({
514
- userId: 'user_42',
515
- limit: 50,
516
- cursor: previousCursor,
517
- });
518
-
519
- await stackbone.memory.update(memoryId, {
520
- content: 'The user prefers replies in Spanish.',
521
- metadata: { reviewed: true },
522
- });
523
-
524
- const { data: events } = await stackbone.memory.history(memoryId);
525
- ```
526
-
527
- Deletion — single fact, all data for a user (GDPR), or end-of-session collapse:
528
-
529
- ```ts
530
- // Forget one fact.
531
- await stackbone.memory.delete(memoryId);
532
-
533
- // GDPR: forget everything we have on this user.
534
- await stackbone.memory.deleteAll({ userId: 'user_42' });
498
+ ### Memory (pending)
535
499
 
536
- // Close a session and consolidate its facts into long-term memory (default).
537
- const { data } = await stackbone.memory.endSession('sess_abc', { persist: true });
538
- // data: { sessionId, persisted }
539
- ```
540
-
541
- > Until the mem0 integration ships, every method returns `{ data: null, error: { code: 'not_implemented', ... } }`. The shape of the responses described here is the contract callers can already write code against.
500
+ Long-term memory is a pending surface: its public types (`AddMemoryRequest`, `MemoryItem`, `MemoryHit`, `MemoryScope`, …) are exported from `@stackbone/sdk` so creators can type their integrations today, but there is no live `client.memory` accessor yet. The mem0-backed runtime will be added in a future release and promoted into the Features list above. See the [Coming Soon](#coming-soon) section for the full pending set.
542
501
 
543
502
  ### Coming Soon
544
503
 
545
- The following modules are part of the public surface but currently return a `not_implemented` error. They will land iteratively as the platform rolls out:
504
+ The following surfaces have stable public **types** (so creators can write code against them today) but no runtime yet. They live under `src/surfaces/pending/` in the SDK source and do NOT appear as `client.X` accessors — importing the types directly from `@stackbone/sdk` is the supported path while the runtime is in flight:
546
505
 
547
506
  ```ts
548
- // Push an HTTP job to another agent (QStash-backed under the hood).
549
- await stackbone.queues.publish({
550
- url: 'https://agent-b.stackbone.ai/invoke',
551
- body: { leadId: 42 },
552
- retries: 3,
553
- });
554
-
555
- // Emit an event to the organization event bus.
556
- await stackbone.events.emit('lead.qualified', { leadId: 42 });
557
-
558
- // List OAuth connections the user attached (Notion, GDrive, Slack…).
559
- const { data: connections } = await stackbone.connections.list();
507
+ import type {
508
+ PublishRequest,
509
+ AddMemoryRequest,
510
+ MemoryHit,
511
+ Prompt,
512
+ CreatePromptRequest,
513
+ } from '@stackbone/sdk';
560
514
  ```
561
515
 
516
+ The pending surfaces are `queues` (cross-container HTTP push), `memory` (mem0-backed long-term memory), `prompts` (managed prompts with `{{var}}` templates), `connections` (OAuth integrations) and `events` (organisation event bus). When their runtime lands they will be promoted into the live `client.X` set described in the Features section above.
517
+
562
518
  ## Configuration
563
519
 
564
- `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:
565
521
 
566
522
  ```ts
567
523
  const stackbone = createClient({
@@ -577,8 +533,6 @@ const stackbone = createClient({
577
533
  // OpenRouter credentials → OPENROUTER_API_KEY / OPENROUTER_BASE_URL
578
534
  openrouterKey: '...',
579
535
  openrouterBaseUrl: 'https://openrouter.ai/api/v1',
580
- // QStash credentials (queues) → QSTASH_TOKEN / QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY
581
- qstashToken: '...',
582
536
  // RAG document parser → LLAMA_PARSE_API_KEY
583
537
  llamaParseApiKey: '...',
584
538
  // mem0 credentials (memory module) → MEM0_API_KEY / MEM0_BASE_URL
@@ -602,7 +556,7 @@ const stackbone = createClient({
602
556
  });
603
557
  ```
604
558
 
605
- 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.
606
560
 
607
561
  ## Result Envelope and Error Handling
608
562
 
@@ -622,7 +576,7 @@ interface SdkError {
622
576
  Narrowing on `error` automatically refines `data` to `T`, so the typical caller looks like:
623
577
 
624
578
  ```ts
625
- const result = await stackbone.ai.chat.completions.create({ model, messages });
579
+ const result = await client.ai.chat.completions.create({ model, messages });
626
580
 
627
581
  if (result.error) {
628
582
  console.error(`[${result.error.code}] ${result.error.message}`, result.error.meta);
@@ -634,18 +588,24 @@ console.log(result.data.choices[0]?.message.content);
634
588
 
635
589
  Each module ships its own stable code prefix so you can pattern-match without parsing the message:
636
590
 
637
- | Prefix | Source | Examples |
638
- | ----------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
639
- | `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated` |
640
- | `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error` |
641
- | `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error` |
642
- | `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed` |
643
- | `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response` |
644
- | `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response` |
645
- | `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented` |
646
- | `http_*` | facade HTTP client | `http_unauthorized`, `http_timeout`, `http_rate_limited`, `http_server_error` |
647
- | `*_missing` | configuration | `database_not_configured`, `agent_id_missing`, `openrouter_key_missing`, `database_url_missing` |
648
- | `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 |
607
+
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.
649
609
 
650
610
  ## TypeScript Support
651
611
 
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';