# @stackbone/sdk

[![License](https://img.shields.io/badge/License-Proprietary-lightgrey.svg)](#license)
[![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg)](https://nodejs.org/)
[![TypeScript](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.typescriptlang.org/)

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.

> "You write the agent. We connect it to the world."

## Features

- **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`
- **Storage** — S3-compatible object storage (Cloudflare R2 in prod, MinIO in dev) with automatic per-agent key prefixing and signed URLs
- **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
- **RAG** — Document parsing, chunking and `pgvector`-backed retrieval through a flat, ceremony-free API
- **Observability** — OpenTelemetry span processor + run-cost aggregator + per-run logger
- **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
- **Secrets** — Read organization-encrypted secrets registered in the dashboard, no client-side cache so rotations propagate immediately
- **Config** — Typed reads of dynamic per-agent configuration the user set in the dashboard
- **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.
- **TypeScript-first** — Full type definitions and a uniform `Result<T>` envelope on every method (no thrown errors at the SDK boundary)
- **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

## Installation

`@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:

```bash
# Alpha (today)
pnpm add @stackbone/sdk@alpha

# Once we cut a stable release the unscoped tag will resolve to the latest
# minor:
pnpm add @stackbone/sdk
```

> 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.

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).

## Quick Start

### The Client

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:

```ts
import { defineAgent, z } from '@stackbone/sdk';

export default defineAgent({
  invoke: {
    input: z.object({ email: z.string() }),
    output: z.object({ id: z.number() }),
    async run({ input, client }) {
      const [row] = await client.database
        .insert(leads)
        .values({ email: input.email, status: 'new', score: 0 })
        .returning();
      return { id: row.id };
    },
  },
});
```

#### Escape hatch — `createClient()`

`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.

```ts
import { createClient } from '@stackbone/sdk';

// Zero-arg: every option falls back to an injected env var.
const stackbone = createClient();

// Or pass overrides explicitly.
const local = createClient({
  agentJwt: process.env.STACKBONE_AGENT_JWT,
  stackboneApiUrl: 'http://localhost:3000',
  agentId: 'agent_local_dev',
  openrouterKey: process.env.OPENROUTER_API_KEY,
});
```

### Database

`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.

```ts
import { defineAgent, z } from '@stackbone/sdk';
import { eq, integer, pgTable, text } from '@stackbone/sdk/db';

// 1. Declare your tables — types flow end to end.
const leads = pgTable('leads', {
  id: integer('id').primaryKey(),
  email: text('email').notNull(),
  status: text('status').notNull(),
  score: integer('score').notNull(),
});

export default defineAgent({
  invoke: {
    input: z.object({}),
    output: z.object({}),
    async run({ client }) {
      // 2. Query — `data` is typed from the schema, no codegen needed.
      const rows = await client.database
        .select()
        .from(leads)
        .where(eq(leads.status, 'qualified'))
        .limit(20);

      // 3. Mutate.
      await client.database
        .insert(leads)
        .values({ id: 1, email: 'jane@example.com', status: 'new', score: 0 });

      await client.database.update(leads).set({ status: 'contacted' }).where(eq(leads.id, 1));

      await client.database.delete(leads).where(eq(leads.id, 1));

      return {};
    },
  },
});
```

The snippets below assume the `client` destructured off `ctx` inside a handler (`async run({ client }) { ... }`).

Interactive transactions yield a `tx` with the same Drizzle surface as `client.database`:

```ts
await client.database.transaction(async (tx) => {
  await tx.insert(leads).values({ id: 2, email: 'b@x.com', status: 'new', score: 0 });
  await tx.update(leads).set({ status: 'qualified' }).where(eq(leads.id, 2));
});
```

For raw SQL, reach for the `sql` tag re-exported from `@stackbone/sdk/db`:

```ts
import { sql } from '@stackbone/sdk/db';

const { rows } = await client.database.execute(sql`SELECT NOW()`);
```

> 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.

### Storage

`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:

```ts
// Upload an object
const { data, error } = await client.storage
  .from('uploads')
  .upload('reports/2026-04-29.pdf', pdfBlob, {
    contentType: 'application/pdf',
    metadata: { source: 'leads-agent' },
  });

// Download an object (returns a Blob)
const { data, error } = await client.storage.from('uploads').download('reports/2026-04-29.pdf');

// List objects (paginated)
const { data, error } = await client.storage
  .from('uploads')
  .list({ prefix: 'reports/', limit: 50 });

// Delete an object
const { data, error } = await client.storage.from('uploads').remove('reports/2026-04-29.pdf');
```

Signed URLs and a public URL helper are exposed for direct browser uploads / downloads:

```ts
// Pre-signed upload URL (1h by default; clamp the contentType into the signature)
const { data: upload } = await client.storage
  .from('uploads')
  .getSignedUploadUrl('avatars/user-42.png', {
    expiresIn: 600,
    contentType: 'image/png',
  });

// Pre-signed download URL — prefer this over `download()` for large objects,
// since `download()` materialises the entire body in memory as a Blob.
const { data: download } = await client.storage
  .from('uploads')
  .getSignedDownloadUrl('reports/2026-04-29.pdf');

// Canonical public URL (only fetchable for public buckets)
const { data: url } = client.storage.from('uploads').getPublicUrl('avatars/user-42.png');
```

### AI

`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.

```ts
// Chat completion
const { data, error } = await client.ai.chat.completions.create({
  model: 'anthropic/claude-sonnet-4.5',
  messages: [
    { role: 'system', content: 'You are a sales qualification assistant.' },
    { role: 'user', content: 'Score this lead: ...' },
  ],
});

if (!error) {
  console.log(data.choices[0]?.message.content);
}
```

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`:

```ts
const { data: stream, error } = await client.ai.chat.completions.create({
  model: 'anthropic/claude-sonnet-4.5',
  messages: [{ role: 'user', content: 'Stream me a haiku.' }],
  stream: true,
});

if (!error) {
  for await (const chunk of stream) {
    process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
  }
}
```

Embeddings, image generation and a model catalogue are first-class too:

```ts
// Embeddings — returns the standard OpenAI shape
const { data, error } = await client.ai.embeddings.create({
  model: 'openai/text-embedding-3-small',
  input: 'A red Spanish sword from the 11th century.',
});

// Image generation — OpenRouter routes image models through chat completions;
// the SDK normalises the response so callers see the OpenAI-shaped payload.
// Returns `ai_no_image_generated` if the model produces zero images.
const { data, error } = await client.ai.images.generate({
  model: 'google/gemini-2.5-flash-image',
  prompt: 'A medieval Spanish sword on a velvet cushion, museum lighting',
});

// List available models — preserves OpenRouter-specific fields like
// `pricing`, `context_length`, `supported_parameters`, `architecture`
const { data, error } = await client.ai.models.list();
```

Cancellation works the way you'd expect — pass an `AbortSignal` and aborts surface as `ai_aborted` on non-streaming calls:

```ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);

const { data, error } = await client.ai.chat.completions.create(
  { model: 'anthropic/claude-sonnet-4.5', messages: [...] },
  { signal: controller.signal },
);
```

### RAG

`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.

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:

```ts
// 1. Parse a document → plain text. Supports text/*, text/markdown and application/pdf.
const text = await client.rag.parse(file);

// 2. Split into chunks. Pure utility, no DB call.
const chunks = client.rag.chunk(text, { size: 512, overlap: 64 });

// 3. Ingest. The SDK embeds the chunks for you in batches of 128 and
//    provisions the schema on the first call (HNSW + cosine, dims inferred
//    from the first batch). Re-ingesting the same `id` atomically replaces
//    all of that document's chunks.
await client.rag.ingest({
  id: 'doc-1',
  chunks,
  model: 'openai/text-embedding-3-small',
  metadata: { source: 'manual-upload' },
});

// 4. Retrieve. Pass the user's question and the same model — the SDK embeds
//    it for you and returns hits scored in [0, 1] (cosine similarity).
const { data: hits } = await client.rag.retrieve({
  text: 'how does X work?',
  model: 'openai/text-embedding-3-small',
  topK: 5,
  filter: { source: 'manual-upload' },
});
```

The escape hatch — pass embeddings precomputed yourself (different provider, custom dimensions, your own batching, deterministic offline tests):

```ts
const { data: embeddings } = await client.ai.embeddings.create({
  model: 'openai/text-embedding-3-small',
  input: chunks,
});

await client.rag.ingest({
  id: 'doc-1',
  chunks: chunks.map((content, i) => ({
    content,
    embedding: embeddings.data[i].embedding,
  })),
  metadata: { source: 'manual-upload' },
});

const { data: hits } = await client.rag.retrieve({
  embedding: queryEmbedding,
  topK: 5,
});
```

Delete by id (whole document) or by metadata predicate:

```ts
await client.rag.delete('doc-1');
await client.rag.delete(['doc-1', 'doc-2']);

await client.rag.deleteWhere({ source: 'manual-upload' });
```

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:

```ts
await client.rag.ingest({
  id: 'faq-1',
  chunks: [...],
  namespace: 'faqs',
});

await client.rag.retrieve({ embedding, namespace: 'faqs' });
await client.rag.deleteWhere({}, { namespace: 'faqs' });
```

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:

```ts
await client.rag.reset();
```

> `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.

### Approval (Human-In-The-Loop)

`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.

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 idle scale-to-zero and arbitrary numbers of concurrent approvals without tying up sockets.

```ts
// 1. Issue an approval — returns immediately, the human reviews asynchronously.
const { data, error } = await client.airoval.request({
  topic: 'lead.qualify',
  payload: { leadId: 42, score: 0.6 },
  title: 'Approve lead qualification',
  description: 'Score is below the auto-qualify threshold (0.7).',
  onDecide: '/approvals/lead-qualify',
  timeout: '24h', // ISO duration or ms; default 24h
  onTimeout: 'reject', // 'reject' | 'approve' | 'ignore'
  approver: 'team:sales',
  idempotencyKey: 'qualify-42',
  metadata: { runId: 'r_123' },
});
// data: { approvalId, status: 'pending', callbackUrl, expiresAt }

// 2. Receive the decision — `verify()` authenticates the inbound POST and
//    returns a typed Decision<T>. Pass the raw `Request` (Fetch / Hono /
//    undici) and switch on `decision.status`.
app.post('/approvals/lead-qualify', async (c) => {
  const result = await client.airoval.verify<{ leadId: number; score: number }>(c.req.raw);
  if (result.error) return c.text('invalid', 401);
  switch (result.data.status) {
    case 'approved':
      await processLead(result.data.payload);
      break;
    case 'rejected':
      await archiveLead(result.data.payload);
      break;
    case 'timed_out':
    case 'cancelled':
      // No payload — only `decidedAt` (and an optional `reason` for cancelled).
      break;
  }
  return c.json({ ok: true });
});
```

Operations on existing approvals:

```ts
await client.airoval.cancel(approvalId, 'no longer needed');
const { data: record } = await client.airoval.get<LeadPayload>(approvalId);
const { data: page } = await client.airoval.list({
  status: 'pending',
  topic: 'lead.qualify',
  limit: 50,
});
```

#### Approval-gated LLM tools

`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.

```ts
const sendInvoice = client.airoval.tool({
  name: 'send_invoice',
  description: 'Send an invoice to a recipient.',
  parameters: {
    type: 'object',
    properties: {
      amount: { type: 'number' },
      recipient: { type: 'string' },
    },
    required: ['amount', 'recipient'],
  },
  needsApproval: ({ amount }) => amount > 1000,
  toRequest: (input) => ({
    onDecide: '/approvals/send-invoice',
    title: `Send invoice for $${input.amount}`,
    description: `Recipient: ${input.recipient}`,
  }),
  execute: async ({ amount, recipient }) => sendInvoiceImpl(amount, recipient),
});

// Pass the tool to chat.completions.create — it speaks the OpenAI shape.
const completion = await client.ai.chat.completions.create({
  model: 'anthropic/claude-sonnet-4.5',
  messages,
  tools: [sendInvoice.openaiSpec()],
});

// Drive the tool calls the LLM emitted.
for (const call of completion.data?.choices[0]?.message.tool_calls ?? []) {
  const r = await sendInvoice.invoke(JSON.parse(call.function.arguments));
  if (r.error) return { error: r.error };
  if (r.data.status === 'pending') {
    // Approval was created. Persist agent state and break the loop —
    // the decision callback will resume it.
    return { pending: r.data.approvalId };
  }
  messages.push({
    role: 'tool',
    tool_call_id: call.id,
    content: JSON.stringify(r.data.result),
  });
}
```

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 })`.

### Secrets

`client.secrets` reads organization-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.

```ts
// Single secret — `secrets_not_found` error if the name is not registered.
const { data: apiKey, error } = await client.secrets.get('STRIPE_API_KEY');
if (!error) {
  const stripe = new Stripe(apiKey);
}

// Bulk read at boot — one round-trip; missing names come back as omissions.
const { data: secrets } = await client.secrets.getMany(['STRIPE_API_KEY', 'SLACK_BOT_TOKEN']);
// secrets: { STRIPE_API_KEY?: string; SLACK_BOT_TOKEN?: string }
```

### Config

`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`.

```ts
// Typed read — the generic narrows `data` to `'formal' | 'casual'`.
const { data: tone } = await client.config.get<'formal' | 'casual'>('reply_tone');

// Complex JSON — pass the full shape as the generic.
interface Preferences {
  tone: 'formal' | 'casual';
  maxEmailsPerDay: number;
}
const { data: prefs } = await client.config.get<Preferences>('preferences');

// Bulk read — typed via the second generic; missing keys come back as omissions.
const { data: cfg } = await client.config.getMany<{
  reply_tone: 'formal' | 'casual';
  max_emails: number;
}>(['reply_tone', 'max_emails']);
```

`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.

### Memory (pending)

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.

### Coming Soon

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:

```ts
import type {
  PublishRequest,
  AddMemoryRequest,
  MemoryHit,
  Prompt,
  CreatePromptRequest,
} from '@stackbone/sdk';
```

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.

## Configuration

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:

```ts
const stackbone = createClient({
  // Ed25519 JWT signed by the control plane                → STACKBONE_AGENT_JWT
  agentJwt: '...',
  // Stackbone control plane base URL (used by facades)        → STACKBONE_API_URL
  stackboneApiUrl: 'https://api.stackbone.ai',
  // Stable agent identifier used as storage key prefix     → STACKBONE_AGENT_ID
  agentId: 'agent_abc123',
  // Postgres connection string for `client.database`, `client.rag` and the
  // observability exporter                                  → STACKBONE_POSTGRES_URL
  databaseUrl: 'postgresql://...',
  // OpenRouter credentials                                  → OPENROUTER_API_KEY / OPENROUTER_BASE_URL
  openrouterKey: '...',
  openrouterBaseUrl: 'https://openrouter.ai/api/v1',
  // RAG document parser                                     → LLAMA_PARSE_API_KEY
  llamaParseApiKey: '...',
  // mem0 credentials (memory module)                        → MEM0_API_KEY / MEM0_BASE_URL
  mem0ApiKey: '...',
  mem0BaseUrl: 'https://api.mem0.ai',
  // HMAC key for verifying approval-decision callbacks       → STACKBONE_APPROVAL_SIGNING_KEY
  approvalSigningKey: '...',
  // Object storage credentials                              → STACKBONE_S3_ACCESS_KEY / STACKBONE_S3_SECRET_KEY / STACKBONE_S3_ENDPOINT / STACKBONE_S3_BUCKET / STACKBONE_S3_REGION
  s3: {
    accessKeyId: '...',
    secretAccessKey: '...',
    endpoint: 'https://<account>.r2.cloudflarestorage.com',
    bucket: 'stackbone-prod',
    region: 'auto',
  },
  // OpenTelemetry exporter
  otel: {
    exporterOtlpEndpoint: 'https://otel.stackbone.ai',
    resourceAttributes: 'service.name=my-agent',
  },
});
```

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.

## Result Envelope and Error Handling

Every SDK method returns the same discriminated union:

```ts
type Result<T> = { data: T; error: null } | { data: null; error: SdkError };

interface SdkError {
  code: string; // stable, machine-readable
  message: string; // human-readable
  cause?: unknown; // upstream error, when relevant
  meta?: Record<string, unknown>; // status code, model, etc.
}
```

Narrowing on `error` automatically refines `data` to `T`, so the typical caller looks like:

```ts
const result = await client.ai.chat.completions.create({ model, messages });

if (result.error) {
  console.error(`[${result.error.code}] ${result.error.message}`, result.error.meta);
  return;
}

console.log(result.data.choices[0]?.message.content);
```

Each module ships its own stable code prefix so you can pattern-match without parsing the message:

| Prefix            | Source                                                          | Examples                                                                                                                                                                                                                                                                                                 |
| ----------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai_*`            | `client.ai`                                                     | `ai_unauthorized`, `ai_credits_exhausted`, `ai_rate_limited`, `ai_aborted`, `ai_no_image_generated`                                                                                                                                                                                                      |
| `s3_*`            | `client.storage`                                                | `s3_credentials_missing`, `s3_invalid_key`, `s3_error`                                                                                                                                                                                                                                                   |
| `rag_*`           | `client.rag`                                                    | `rag_invalid_request`, `rag_dim_mismatch`, `rag_embedding_failed`, `rag_error`                                                                                                                                                                                                                           |
| `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` |
| `secrets_*`       | `client.secrets`                                                | `secrets_invalid_request`, `secrets_not_found`, `secrets_invalid_response`, `secrets_unauthorized`, `secrets_forbidden`, `secrets_rate_limited`, `secrets_unavailable`                                                                                                                                   |
| `config_*`        | `client.config`                                                 | `config_invalid_request`, `config_not_found`, `config_invalid_response`, `config_unauthorized`, `config_forbidden`, `config_rate_limited`, `config_unavailable`                                                                                                                                          |
| `memory_*`        | `client.memory`                                                 | reserved for the future mem0-backed implementation; today every method returns `not_implemented`                                                                                                                                                                                                         |
| `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                                                                                                                              |
| `database_*`      | `client.database`                                               | `database_not_configured` (raised when `STACKBONE_POSTGRES_URL` is unset)                                                                                                                                                                                                                                |
| `observability_*` | run-cost rollup (`@stackbone/sdk/observability`, runtime-wired) | `observability_close_run_failed`                                                                                                                                                                                                                                                                         |
| `contract_*`      | gated surfaces                                                  | `contract_malformed`, `contract_unreachable`, `contract_version_unsupported` — emitted by any gated call when the contract handshake fails                                                                                                                                                               |
| `capability_*`    | gated surfaces                                                  | `capability_unavailable` — emitted when the negotiated contract advertises no support for the surface                                                                                                                                                                                                    |
| `*_missing`       | configuration                                                   | `agent_id_missing`, `openrouter_key_missing`, `database_url_missing`, `stackbone_api_url_missing`                                                                                                                                                                                                        |
| `not_implemented` | stubbed module surface                                          | returned by every "coming soon" method until it ships                                                                                                                                                                                                                                                    |

> 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.

## TypeScript Support

The SDK is written in TypeScript and ships its own type definitions:

```ts
import { createClient, StackboneClient, type Result, type SdkError } from '@stackbone/sdk';

const stackbone: StackboneClient = createClient();

const result = await stackbone.ai.chat.completions.create({
  model: 'anthropic/claude-sonnet-4.5',
  messages: [{ role: 'user', content: 'Hello' }],
});
```

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.

For typed queue payloads, augment the `QueueJobs` registry once and both publisher and consumer pick up the shape:

```ts
declare module '@stackbone/sdk/queues/types' {
  interface QueueJobs {
    'lead.qualified': { leadId: number; score: number };
    'send-report': { reportId: string; recipient: string };
  }
}
```

## Runtime

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.

## Documentation

- **[Stackbone TECH_STACK](../../docs/TECH_STACK.md)** — Normative stack and platform primitives
- **[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
- **[Componente — SDK & Creator DX](../../docs/arquitectura/componentes/04-sdk-creator-dx.md)** — End-to-end developer experience for agent creators

## License

Proprietary © Stackbone. All rights reserved. This package is published privately for the Stackbone platform; it is not licensed for redistribution.

---

Built with ❤️ by the Stackbone team
