@stackbone/sdk 0.1.0-alpha.0
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 +34 -0
- package/README.md +693 -0
- package/db/index.cjs +21 -0
- package/db/index.cjs.map +1 -0
- package/db/index.d.cts +3 -0
- package/db/index.d.ts +3 -0
- package/db/index.js +4 -0
- package/db/index.js.map +1 -0
- package/db/testing/index.cjs +83 -0
- package/db/testing/index.cjs.map +1 -0
- package/db/testing/index.d.cts +48 -0
- package/db/testing/index.d.ts +48 -0
- package/db/testing/index.js +77 -0
- package/db/testing/index.js.map +1 -0
- package/index.cjs +19977 -0
- package/index.cjs.map +1 -0
- package/index.d.cts +1325 -0
- package/index.d.ts +1325 -0
- package/index.js +19961 -0
- package/index.js.map +1 -0
- package/package.json +108 -0
- package/queues/types.cjs +4 -0
- package/queues/types.cjs.map +1 -0
- package/queues/types.d.cts +14 -0
- package/queues/types.d.ts +14 -0
- package/queues/types.js +3 -0
- package/queues/types.js.map +1 -0
- package/rag/migrations/index.cjs +71 -0
- package/rag/migrations/index.cjs.map +1 -0
- package/rag/migrations/index.d.cts +29 -0
- package/rag/migrations/index.d.ts +29 -0
- package/rag/migrations/index.js +66 -0
- package/rag/migrations/index.js.map +1 -0
- package/rag/schema.cjs +124 -0
- package/rag/schema.cjs.map +1 -0
- package/rag/schema.d.cts +446 -0
- package/rag/schema.d.ts +446 -0
- package/rag/schema.js +96 -0
- package/rag/schema.js.map +1 -0
- package/stackbone-sdk-0.1.0-alpha.0.tgz +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# @stackbone/sdk
|
|
2
|
+
|
|
3
|
+
[](#license)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
|
|
7
|
+
Official TypeScript SDK for [Stackbone](https://stackbone.ai) — the marketplace and managed runtime for containerized AI agents. Stackbone provisions a Postgres branch, object storage, an LLM gateway and the rest of the platform primitives for every agent; this SDK is the in-container surface those agents call.
|
|
8
|
+
|
|
9
|
+
> "You write the agent. We connect it to the world."
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
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
|
+
- **Storage** — S3-compatible object storage (Cloudflare R2 in prod, MinIO in dev) with automatic per-agent key prefixing and signed URLs
|
|
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
|
+
- **RAG** — Document parsing, chunking and `pgvector`-backed retrieval through a flat, ceremony-free API
|
|
18
|
+
- **Memory** _(coming soon)_ — 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** _(coming soon)_ — 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
|
+
- **Observability** _(coming soon)_ — OpenTelemetry traces and logs flushed through the platform collector
|
|
21
|
+
- **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
|
+
- **Secrets** — Read workspace-encrypted secrets registered in the dashboard, no client-side cache so rotations propagate immediately
|
|
23
|
+
- **Config** — Typed reads of dynamic per-agent configuration the user set in the dashboard
|
|
24
|
+
- **Connections / Events** _(coming soon)_ — OAuth integrations (Notion, GDrive, Slack…) and a workspace event bus
|
|
25
|
+
- **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
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
`@stackbone/sdk` is the SDK every published agent depends on. Inside the Stackbone monorepo it is available via the `@stackbone/sdk` workspace alias. From a generated agent project:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Alpha (today)
|
|
34
|
+
pnpm add @stackbone/sdk@alpha
|
|
35
|
+
|
|
36
|
+
# Once we cut a stable release the unscoped tag will resolve to the latest
|
|
37
|
+
# minor:
|
|
38
|
+
pnpm add @stackbone/sdk
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> 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
|
+
|
|
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).
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### Initialize the Client
|
|
48
|
+
|
|
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:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createClient } from '@stackbone/sdk';
|
|
53
|
+
|
|
54
|
+
const stackbone = createClient();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Pass overrides explicitly when you need them — useful for local development against the emulator, or when running outside a Stackbone container:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { createClient } from '@stackbone/sdk';
|
|
61
|
+
|
|
62
|
+
const stackbone = createClient({
|
|
63
|
+
agentJwt: process.env.STACKBONE_AGENT_JWT,
|
|
64
|
+
stackboneApiUrl: 'http://localhost:3000',
|
|
65
|
+
agentId: 'agent_local_dev',
|
|
66
|
+
openrouterKey: process.env.OPENROUTER_API_KEY,
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Database
|
|
71
|
+
|
|
72
|
+
`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
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createClient } from '@stackbone/sdk';
|
|
76
|
+
import { eq, integer, pgTable, text } from '@stackbone/sdk/db';
|
|
77
|
+
|
|
78
|
+
// 1. Declare your tables — types flow end to end.
|
|
79
|
+
const leads = pgTable('leads', {
|
|
80
|
+
id: integer('id').primaryKey(),
|
|
81
|
+
email: text('email').notNull(),
|
|
82
|
+
status: text('status').notNull(),
|
|
83
|
+
score: integer('score').notNull(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const stackbone = createClient();
|
|
87
|
+
|
|
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);
|
|
94
|
+
|
|
95
|
+
// 3. Mutate.
|
|
96
|
+
await stackbone.database
|
|
97
|
+
.insert(leads)
|
|
98
|
+
.values({ id: 1, email: 'jane@example.com', status: 'new', score: 0 });
|
|
99
|
+
|
|
100
|
+
await stackbone.database.update(leads).set({ status: 'contacted' }).where(eq(leads.id, 1));
|
|
101
|
+
|
|
102
|
+
await stackbone.database.delete(leads).where(eq(leads.id, 1));
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Interactive transactions yield a `tx` with the same Drizzle surface as `client.database`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
await stackbone.database.transaction(async (tx) => {
|
|
109
|
+
await tx.insert(leads).values({ id: 2, email: 'b@x.com', status: 'new', score: 0 });
|
|
110
|
+
await tx.update(leads).set({ status: 'qualified' }).where(eq(leads.id, 2));
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For raw SQL, reach for the `sql` tag re-exported from `@stackbone/sdk/db`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { sql } from '@stackbone/sdk/db';
|
|
118
|
+
|
|
119
|
+
const { rows } = await stackbone.database.execute(sql`SELECT NOW()`);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> 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.
|
|
123
|
+
|
|
124
|
+
### Storage
|
|
125
|
+
|
|
126
|
+
`client.storage` is bucket-scoped via `from(bucket)` and transparently prefixes every key with `${agentId}/${bucket}/` before talking to S3 / R2 / MinIO, so your agent code never has to think about multi-tenancy:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Upload an object
|
|
130
|
+
const { data, error } = await stackbone.storage
|
|
131
|
+
.from('uploads')
|
|
132
|
+
.upload('reports/2026-04-29.pdf', pdfBlob, {
|
|
133
|
+
contentType: 'application/pdf',
|
|
134
|
+
metadata: { source: 'leads-agent' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Download an object (returns a Blob)
|
|
138
|
+
const { data, error } = await stackbone.storage.from('uploads').download('reports/2026-04-29.pdf');
|
|
139
|
+
|
|
140
|
+
// List objects (paginated)
|
|
141
|
+
const { data, error } = await stackbone.storage
|
|
142
|
+
.from('uploads')
|
|
143
|
+
.list({ prefix: 'reports/', limit: 50 });
|
|
144
|
+
|
|
145
|
+
// Delete an object
|
|
146
|
+
const { data, error } = await stackbone.storage.from('uploads').remove('reports/2026-04-29.pdf');
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Signed URLs and a public URL helper are exposed for direct browser uploads / downloads:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// Pre-signed upload URL (1h by default; clamp the contentType into the signature)
|
|
153
|
+
const { data: upload } = await stackbone.storage
|
|
154
|
+
.from('uploads')
|
|
155
|
+
.getSignedUploadUrl('avatars/user-42.png', {
|
|
156
|
+
expiresIn: 600,
|
|
157
|
+
contentType: 'image/png',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Pre-signed download URL — prefer this over `download()` for large objects,
|
|
161
|
+
// since `download()` materialises the entire body in memory as a Blob.
|
|
162
|
+
const { data: download } = await stackbone.storage
|
|
163
|
+
.from('uploads')
|
|
164
|
+
.getSignedDownloadUrl('reports/2026-04-29.pdf');
|
|
165
|
+
|
|
166
|
+
// Canonical public URL (only fetchable for public buckets)
|
|
167
|
+
const { data: url } = stackbone.storage.from('uploads').getPublicUrl('avatars/user-42.png');
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### AI
|
|
171
|
+
|
|
172
|
+
`client.ai` mirrors the official `openai` SDK shape but every request is routed through OpenRouter, so you can pick from 300+ models with a single API key. The Stackbone platform attributes usage and billing back to the agent through OpenRouter Activity — no proxy in the middle.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Chat completion
|
|
176
|
+
const { data, error } = await stackbone.ai.chat.completions.create({
|
|
177
|
+
model: 'anthropic/claude-sonnet-4.5',
|
|
178
|
+
messages: [
|
|
179
|
+
{ role: 'system', content: 'You are a sales qualification assistant.' },
|
|
180
|
+
{ role: 'user', content: 'Score this lead: ...' },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!error) {
|
|
185
|
+
console.log(data.choices[0]?.message.content);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
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
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const { data: stream, error } = await stackbone.ai.chat.completions.create({
|
|
193
|
+
model: 'anthropic/claude-sonnet-4.5',
|
|
194
|
+
messages: [{ role: 'user', content: 'Stream me a haiku.' }],
|
|
195
|
+
stream: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!error) {
|
|
199
|
+
for await (const chunk of stream) {
|
|
200
|
+
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Embeddings, image generation and a model catalogue are first-class too:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// Embeddings — returns the standard OpenAI shape
|
|
209
|
+
const { data, error } = await stackbone.ai.embeddings.create({
|
|
210
|
+
model: 'openai/text-embedding-3-small',
|
|
211
|
+
input: 'A red Spanish sword from the 11th century.',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Image generation — OpenRouter routes image models through chat completions;
|
|
215
|
+
// the SDK normalises the response so callers see the OpenAI-shaped payload.
|
|
216
|
+
// Returns `ai_no_image_generated` if the model produces zero images.
|
|
217
|
+
const { data, error } = await stackbone.ai.images.generate({
|
|
218
|
+
model: 'google/gemini-2.5-flash-image',
|
|
219
|
+
prompt: 'A medieval Spanish sword on a velvet cushion, museum lighting',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// List available models — preserves OpenRouter-specific fields like
|
|
223
|
+
// `pricing`, `context_length`, `supported_parameters`, `architecture`
|
|
224
|
+
const { data, error } = await stackbone.ai.models.list();
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Cancellation works the way you'd expect — pass an `AbortSignal` and aborts surface as `ai_aborted` on non-streaming calls:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
232
|
+
|
|
233
|
+
const { data, error } = await stackbone.ai.chat.completions.create(
|
|
234
|
+
{ model: 'anthropic/claude-sonnet-4.5', messages: [...] },
|
|
235
|
+
{ signal: controller.signal },
|
|
236
|
+
);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### RAG
|
|
240
|
+
|
|
241
|
+
`client.rag` exposes a flat, ceremony-free retrieval API on top of `pgvector` running in the agent's Postgres branch. There are no collections to create, no handles to thread through your code: the schema is auto-provisioned on the first `ingest()` and the embedding dimensions are inferred from the first chunk.
|
|
242
|
+
|
|
243
|
+
The fast path lets the SDK handle embeddings for you — pass an embedding `model` and the chunks as plain strings, and `client.rag` calls `client.ai.embeddings.create` internally with batching:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// 1. Parse a document → plain text. Supports text/*, text/markdown and application/pdf.
|
|
247
|
+
const text = await stackbone.rag.parse(file);
|
|
248
|
+
|
|
249
|
+
// 2. Split into chunks. Pure utility, no DB call.
|
|
250
|
+
const chunks = stackbone.rag.chunk(text, { size: 512, overlap: 64 });
|
|
251
|
+
|
|
252
|
+
// 3. Ingest. The SDK embeds the chunks for you in batches of 128 and
|
|
253
|
+
// provisions the schema on the first call (HNSW + cosine, dims inferred
|
|
254
|
+
// from the first batch). Re-ingesting the same `id` atomically replaces
|
|
255
|
+
// all of that document's chunks.
|
|
256
|
+
await stackbone.rag.ingest({
|
|
257
|
+
id: 'doc-1',
|
|
258
|
+
chunks,
|
|
259
|
+
model: 'openai/text-embedding-3-small',
|
|
260
|
+
metadata: { source: 'manual-upload' },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 4. Retrieve. Pass the user's question and the same model — the SDK embeds
|
|
264
|
+
// it for you and returns hits scored in [0, 1] (cosine similarity).
|
|
265
|
+
const { data: hits } = await stackbone.rag.retrieve({
|
|
266
|
+
text: 'how does X work?',
|
|
267
|
+
model: 'openai/text-embedding-3-small',
|
|
268
|
+
topK: 5,
|
|
269
|
+
filter: { source: 'manual-upload' },
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The escape hatch — pass embeddings precomputed yourself (different provider, custom dimensions, your own batching, deterministic offline tests):
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const { data: embeddings } = await stackbone.ai.embeddings.create({
|
|
277
|
+
model: 'openai/text-embedding-3-small',
|
|
278
|
+
input: chunks,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await stackbone.rag.ingest({
|
|
282
|
+
id: 'doc-1',
|
|
283
|
+
chunks: chunks.map((content, i) => ({
|
|
284
|
+
content,
|
|
285
|
+
embedding: embeddings.data[i].embedding,
|
|
286
|
+
})),
|
|
287
|
+
metadata: { source: 'manual-upload' },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const { data: hits } = await stackbone.rag.retrieve({
|
|
291
|
+
embedding: queryEmbedding,
|
|
292
|
+
topK: 5,
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Delete by id (whole document) or by metadata predicate:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await stackbone.rag.delete('doc-1');
|
|
300
|
+
await stackbone.rag.delete(['doc-1', 'doc-2']);
|
|
301
|
+
|
|
302
|
+
await stackbone.rag.deleteWhere({ source: 'manual-upload' });
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
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
|
+
|
|
307
|
+
```ts
|
|
308
|
+
await stackbone.rag.ingest({
|
|
309
|
+
id: 'faq-1',
|
|
310
|
+
chunks: [...],
|
|
311
|
+
namespace: 'faqs',
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await stackbone.rag.retrieve({ embedding, namespace: 'faqs' });
|
|
315
|
+
await stackbone.rag.deleteWhere({}, { namespace: 'faqs' });
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
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
|
+
|
|
320
|
+
```ts
|
|
321
|
+
await stackbone.rag.reset();
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
> `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.
|
|
325
|
+
|
|
326
|
+
### Approval (Human-In-The-Loop)
|
|
327
|
+
|
|
328
|
+
`client.approval` exposes a **fire-and-forget** primitive: the agent issues an approval request, the call returns immediately with the `approvalId` and a `callbackUrl`, and the control plane POSTs the human's decision to the endpoint the agent registered in `onDecide`. The callback is HMAC-signed (`Stackbone-Signature: t=<unix>,v1=<hex>`) so the agent can authenticate it without storing any per-approval secret.
|
|
329
|
+
|
|
330
|
+
This shape decouples waiting from the container lifecycle — the agent does not have to keep a long-poll connection alive while a human reviews, which means it survives Neon scale-to-zero and arbitrary numbers of concurrent approvals without tying up sockets.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// 1. Issue an approval — returns immediately, the human reviews asynchronously.
|
|
334
|
+
const { data, error } = await stackbone.airoval.request({
|
|
335
|
+
topic: 'lead.qualify',
|
|
336
|
+
payload: { leadId: 42, score: 0.6 },
|
|
337
|
+
title: 'Approve lead qualification',
|
|
338
|
+
description: 'Score is below the auto-qualify threshold (0.7).',
|
|
339
|
+
onDecide: '/approvals/lead-qualify',
|
|
340
|
+
timeout: '24h', // ISO duration or ms; default 24h
|
|
341
|
+
onTimeout: 'reject', // 'reject' | 'approve' | 'ignore'
|
|
342
|
+
approver: 'team:sales',
|
|
343
|
+
idempotencyKey: 'qualify-42',
|
|
344
|
+
metadata: { runId: 'r_123' },
|
|
345
|
+
});
|
|
346
|
+
// data: { approvalId, status: 'pending', callbackUrl, expiresAt }
|
|
347
|
+
|
|
348
|
+
// 2. Receive the decision — `verify()` authenticates the inbound POST and
|
|
349
|
+
// returns a typed Decision<T>. Pass the raw `Request` (Fetch / Hono /
|
|
350
|
+
// undici) and switch on `decision.status`.
|
|
351
|
+
app.post('/approvals/lead-qualify', async (c) => {
|
|
352
|
+
const result = await stackbone.airoval.verify<{ leadId: number; score: number }>(c.req.raw);
|
|
353
|
+
if (result.error) return c.text('invalid', 401);
|
|
354
|
+
switch (result.data.status) {
|
|
355
|
+
case 'approved':
|
|
356
|
+
await processLead(result.data.payload);
|
|
357
|
+
break;
|
|
358
|
+
case 'rejected':
|
|
359
|
+
await archiveLead(result.data.payload);
|
|
360
|
+
break;
|
|
361
|
+
case 'timed_out':
|
|
362
|
+
case 'cancelled':
|
|
363
|
+
// No payload — only `decidedAt` (and an optional `reason` for cancelled).
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
return c.json({ ok: true });
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Operations on existing approvals:
|
|
371
|
+
|
|
372
|
+
```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({
|
|
376
|
+
status: 'pending',
|
|
377
|
+
topic: 'lead.qualify',
|
|
378
|
+
limit: 50,
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
#### Approval-gated LLM tools
|
|
383
|
+
|
|
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.
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
const sendInvoice = stackbone.airoval.tool({
|
|
388
|
+
name: 'send_invoice',
|
|
389
|
+
description: 'Send an invoice to a recipient.',
|
|
390
|
+
parameters: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
properties: {
|
|
393
|
+
amount: { type: 'number' },
|
|
394
|
+
recipient: { type: 'string' },
|
|
395
|
+
},
|
|
396
|
+
required: ['amount', 'recipient'],
|
|
397
|
+
},
|
|
398
|
+
needsApproval: ({ amount }) => amount > 1000,
|
|
399
|
+
toRequest: (input) => ({
|
|
400
|
+
onDecide: '/approvals/send-invoice',
|
|
401
|
+
title: `Send invoice for $${input.amount}`,
|
|
402
|
+
description: `Recipient: ${input.recipient}`,
|
|
403
|
+
}),
|
|
404
|
+
execute: async ({ amount, recipient }) => sendInvoiceImpl(amount, recipient),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Pass the tool to chat.completions.create — it speaks the OpenAI shape.
|
|
408
|
+
const completion = await stackbone.ai.chat.completions.create({
|
|
409
|
+
model: 'anthropic/claude-sonnet-4.5',
|
|
410
|
+
messages,
|
|
411
|
+
tools: [sendInvoice.openaiSpec()],
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Drive the tool calls the LLM emitted.
|
|
415
|
+
for (const call of completion.data?.choices[0]?.message.tool_calls ?? []) {
|
|
416
|
+
const r = await sendInvoice.invoke(JSON.parse(call.function.arguments));
|
|
417
|
+
if (r.error) return { error: r.error };
|
|
418
|
+
if (r.data.status === 'pending') {
|
|
419
|
+
// Approval was created. Persist agent state and break the loop —
|
|
420
|
+
// the decision callback will resume it.
|
|
421
|
+
return { pending: r.data.approvalId };
|
|
422
|
+
}
|
|
423
|
+
messages.push({
|
|
424
|
+
role: 'tool',
|
|
425
|
+
tool_call_id: call.id,
|
|
426
|
+
content: JSON.stringify(r.data.result),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The signing key for `verify()` is read from `STACKBONE_APPROVAL_SIGNING_KEY` (or `approvalSigningKey` on `createClient`). Timestamp tolerance defaults to 300 seconds; tighten or override per call with `verify(req, { toleranceSeconds: 60 })`.
|
|
432
|
+
|
|
433
|
+
### Secrets
|
|
434
|
+
|
|
435
|
+
`client.secrets` reads workspace-encrypted secrets the user registered in the dashboard. The SDK is **read-only** by design — secrets are managed in the control plane, never written from inside an agent container. There is no client-side cache: every `get()` is a network call so dashboard rotations propagate immediately.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
// Single secret — `secrets_not_found` error if the name is not registered.
|
|
439
|
+
const { data: apiKey, error } = await stackbone.secrets.get('STRIPE_API_KEY');
|
|
440
|
+
if (!error) {
|
|
441
|
+
const stripe = new Stripe(apiKey);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 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']);
|
|
446
|
+
// secrets: { STRIPE_API_KEY?: string; SLACK_BOT_TOKEN?: string }
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Config
|
|
450
|
+
|
|
451
|
+
`client.config` reads dynamic per-agent configuration the user set in the dashboard (tone, thresholds, feature flags, prompts…). Like `secrets`, it is read-only and uncached — and the value is fully typed via the generic on `get`.
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
// Typed read — the generic narrows `data` to `'formal' | 'casual'`.
|
|
455
|
+
const { data: tone } = await stackbone.config.get<'formal' | 'casual'>('reply_tone');
|
|
456
|
+
|
|
457
|
+
// Complex JSON — pass the full shape as the generic.
|
|
458
|
+
interface Preferences {
|
|
459
|
+
tone: 'formal' | 'casual';
|
|
460
|
+
maxEmailsPerDay: number;
|
|
461
|
+
}
|
|
462
|
+
const { data: prefs } = await stackbone.config.get<Preferences>('preferences');
|
|
463
|
+
|
|
464
|
+
// Bulk read — typed via the second generic; missing keys come back as omissions.
|
|
465
|
+
const { data: cfg } = await stackbone.config.getMany<{
|
|
466
|
+
reply_tone: 'formal' | 'casual';
|
|
467
|
+
max_emails: number;
|
|
468
|
+
}>(['reply_tone', 'max_emails']);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
`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 workspace has registered — no error for missing entries.
|
|
472
|
+
|
|
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' });
|
|
535
|
+
|
|
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.
|
|
542
|
+
|
|
543
|
+
### Coming Soon
|
|
544
|
+
|
|
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:
|
|
546
|
+
|
|
547
|
+
```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 workspace 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();
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## Configuration
|
|
563
|
+
|
|
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:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
const stackbone = createClient({
|
|
568
|
+
// Ed25519 JWT signed by the control plane → STACKBONE_AGENT_JWT
|
|
569
|
+
agentJwt: '...',
|
|
570
|
+
// Stackbone control plane base URL (used by facades) → STACKBONE_API_URL
|
|
571
|
+
stackboneApiUrl: 'https://api.stackbone.ai',
|
|
572
|
+
// Stable agent identifier used as storage key prefix → STACKBONE_AGENT_ID
|
|
573
|
+
agentId: 'agent_abc123',
|
|
574
|
+
// Postgres connection string for `client.rag` / observability (escape hatch).
|
|
575
|
+
// Note: `client.database` reads `STACKBONE_POSTGRES_URL`, not this one.
|
|
576
|
+
databaseUrl: 'postgresql://...',
|
|
577
|
+
// OpenRouter credentials → OPENROUTER_API_KEY / OPENROUTER_BASE_URL
|
|
578
|
+
openrouterKey: '...',
|
|
579
|
+
openrouterBaseUrl: 'https://openrouter.ai/api/v1',
|
|
580
|
+
// QStash credentials (queues) → QSTASH_TOKEN / QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY
|
|
581
|
+
qstashToken: '...',
|
|
582
|
+
// RAG document parser → LLAMA_PARSE_API_KEY
|
|
583
|
+
llamaParseApiKey: '...',
|
|
584
|
+
// mem0 credentials (memory module) → MEM0_API_KEY / MEM0_BASE_URL
|
|
585
|
+
mem0ApiKey: '...',
|
|
586
|
+
mem0BaseUrl: 'https://api.mem0.ai',
|
|
587
|
+
// HMAC key for verifying approval-decision callbacks → STACKBONE_APPROVAL_SIGNING_KEY
|
|
588
|
+
approvalSigningKey: '...',
|
|
589
|
+
// Object storage credentials → AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / S3_ENDPOINT / S3_BUCKET
|
|
590
|
+
s3: {
|
|
591
|
+
accessKeyId: '...',
|
|
592
|
+
secretAccessKey: '...',
|
|
593
|
+
endpoint: 'https://<account>.r2.cloudflarestorage.com',
|
|
594
|
+
bucket: 'stackbone-prod',
|
|
595
|
+
},
|
|
596
|
+
// OpenTelemetry exporter
|
|
597
|
+
otel: {
|
|
598
|
+
exporterOtlpEndpoint: 'https://otel.stackbone.ai',
|
|
599
|
+
resourceAttributes: 'service.name=my-agent',
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
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.
|
|
605
|
+
|
|
606
|
+
## Result Envelope and Error Handling
|
|
607
|
+
|
|
608
|
+
Every SDK method returns the same discriminated union:
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
type Result<T> = { data: T; error: null } | { data: null; error: SdkError };
|
|
612
|
+
|
|
613
|
+
interface SdkError {
|
|
614
|
+
code: string; // stable, machine-readable
|
|
615
|
+
message: string; // human-readable
|
|
616
|
+
cause?: unknown; // upstream error, when relevant
|
|
617
|
+
meta?: Record<string, unknown>; // status code, model, etc.
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Narrowing on `error` automatically refines `data` to `T`, so the typical caller looks like:
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
const result = await stackbone.ai.chat.completions.create({ model, messages });
|
|
625
|
+
|
|
626
|
+
if (result.error) {
|
|
627
|
+
console.error(`[${result.error.code}] ${result.error.message}`, result.error.meta);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
console.log(result.data.choices[0]?.message.content);
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Each module ships its own stable code prefix so you can pattern-match without parsing the message:
|
|
635
|
+
|
|
636
|
+
| Prefix | Source | Examples |
|
|
637
|
+
| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
638
|
+
| `ai_*` | `client.ai` | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated` |
|
|
639
|
+
| `s3_*` | `client.storage` | `s3_credentials_missing`, `s3_invalid_key`, `s3_error` |
|
|
640
|
+
| `rag_*` | `client.rag` | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error` |
|
|
641
|
+
| `approval_*` | `client.approval` | `approval_invalid_request`, `approval_invalid_signature`, `approval_signature_expired`, `approval_signing_key_missing`, `approval_invalid_payload`, `approval_tool_execute_failed` |
|
|
642
|
+
| `secrets_*` | `client.secrets` | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response` |
|
|
643
|
+
| `config_*` | `client.config` | `config_invalid_request`, `config_not_found`, `config_invalid_response` |
|
|
644
|
+
| `memory_*` | `client.memory` | reserved for the future mem0-backed implementation; today every method returns `not_implemented` |
|
|
645
|
+
| `http_*` | facade HTTP client | `http_unauthorized`, `http_timeout`, `http_rate_limited`, `http_server_error` |
|
|
646
|
+
| `*_missing` | configuration | `database_not_configured`, `agent_id_missing`, `openrouter_key_missing`, `database_url_missing` |
|
|
647
|
+
| `not_implemented` | stubbed module surface | returned by every "coming soon" method until it ships |
|
|
648
|
+
|
|
649
|
+
## TypeScript Support
|
|
650
|
+
|
|
651
|
+
The SDK is written in TypeScript and ships its own type definitions:
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
import { createClient, StackboneClient, type Result, type SdkError } from '@stackbone/sdk';
|
|
655
|
+
|
|
656
|
+
const stackbone: StackboneClient = createClient();
|
|
657
|
+
|
|
658
|
+
const result = await stackbone.ai.chat.completions.create({
|
|
659
|
+
model: 'anthropic/claude-sonnet-4.5',
|
|
660
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Chat, embeddings and streaming types are re-exported directly from the `openai` package — by design, an agent author who already knows the OpenAI SDK does not have to relearn anything.
|
|
665
|
+
|
|
666
|
+
For typed queue payloads, augment the `QueueJobs` registry once and both publisher and consumer pick up the shape:
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
declare module '@stackbone/sdk/queues/types' {
|
|
670
|
+
interface QueueJobs {
|
|
671
|
+
'lead.qualified': { leadId: number; score: number };
|
|
672
|
+
'send-report': { reportId: string; recipient: string };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
## Runtime
|
|
678
|
+
|
|
679
|
+
The SDK targets Node.js 24+ inside the agent container. It is not designed for browser bundles — direct database access, S3 SDK usage and the agent JWT all assume a server-side runtime. For browser-side flows (uploads, public asset access), use the signed URLs returned by `client.storage` and let the browser hit S3 directly.
|
|
680
|
+
|
|
681
|
+
## Documentation
|
|
682
|
+
|
|
683
|
+
- **[Stackbone TECH_STACK](../../docs/TECH_STACK.md)** — Normative stack and platform primitives
|
|
684
|
+
- **[ADR — SDK monolítico del agente](../../docs/arquitectura/decisiones/2026-04-24-sdk-monolitico-agente.md)** — Why a single SDK, scope of every module, error model
|
|
685
|
+
- **[Componente — SDK & Creator DX](../../docs/arquitectura/componentes/04-sdk-creator-dx.md)** — End-to-end developer experience for agent creators
|
|
686
|
+
|
|
687
|
+
## License
|
|
688
|
+
|
|
689
|
+
Proprietary © Stackbone. All rights reserved. This package is published privately for the Stackbone platform; it is not licensed for redistribution.
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
Built with ❤️ by the Stackbone team
|