@stackbone/sdk
v0.2.6
Published
Official TypeScript SDK for Stackbone — the marketplace and managed runtime for containerized AI agents.
Maintainers
Readme
@stackbone/sdk
Official TypeScript SDK for Stackbone — the marketplace and managed runtime for AI agents. You author a workspace of durable agents and workflows; Stackbone provisions a Postgres branch (Neon), object storage, an LLM gateway and the rest of the platform primitives for it, and this SDK is the in-workspace surface your code calls through the ambient stackbone client.
"You write the agent. We connect it to the world."
Features
- Ambient client —
import { stackbone } from '@stackbone/sdk'and reach every platform surface from inside any tool'sexecute()or any workflow step. NocreateClient(), no credential wiring — config is resolved lazily from the env the runtime injects - Database — Native Drizzle ORM over the agent's own Neon Postgres, with type-safe queries, transactions,
pgvectorandtsvector. Drizzle is re-exported via@stackbone/sdk/dbso yourpackage.jsononly 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 — OpenAI-compatible client pointed at OpenRouter, so 300+ chat, embedding and image models are reachable through a single managed sub-key
- RAG — Document ingest and hybrid (
pgvector+tsvector) retrieval through a flat, ceremony-free API; tables are platform-provisioned - Workflows — Durable, replayable orchestration: start, wait on, and schedule workflows by name, and pause on human approval, via
@stackbone/sdk/workflow - Human-in-the-loop —
requestApproval()pauses a workflow durably until a person decides in the Studio inbox (or the timeout fires) - Connectors — Call a brokered third-party integration (Slack, Gmail, …) with
stackbone.connection(id)— no token ever enters the container - Config, secrets & prompts — Typed reads of dynamic per-agent config, organization-encrypted secrets, and a versioned prompt catalog
- TypeScript-first — Full type definitions and a uniform
Result<{ data, error }>envelope on every method (the two exceptions throw — see below)
Installation
@stackbone/sdk is the SDK every Stackbone workspace depends on. From a generated workspace:
pnpm add @stackbone/sdk@stackbone/sdk declares deepagents + @langchain/* and workflow as optional peer dependencies. Install only the ones your workspace uses (the stackbone CLI scaffolds them for you):
# to author a deep agent (deep-agents/<name>/index.ts + LangChain tools)
pnpm add deepagents @langchain/core @langchain/langgraph @langchain/openai
pnpm add workflow # to author a durable workflow that pauses for approvalA tool-only workflow that never imports @stackbone/sdk/deep or @stackbone/sdk/connect never loads those peers, so import { stackbone } from '@stackbone/sdk' stays peer-free.
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.
Scaffold a workspace with the
stackboneCLI (stackbone init,stackbone add agent|workflow). The dev loop, publishing and migrations are CLI concerns — see the Stackbone CLI docs.
What you build — a workspace
A workspace is a project that contains agents and workflows, discovered by convention from the files on disk:
my-workspace/
deep-agents/
support/
index.ts ← the whole agent: default export = defineDeepAgent({ model, systemPrompt, tools })
workflows/
onboarding.workflow.ts ← 'use workflow' fn + sibling inputSchema / outputSchema
schema.ts ← Drizzle tables for stackbone.database
config.schema.ts ← optional: typed dynamic config (Zod)
stackbone.config.ts ← OPTIONAL override of the convention scan- Every
deep-agents/<name>/index.ts(default-exportingdefineDeepAgent(...)) is a deep agent, discovered by its folder basename. - Every
workflows/<name>.workflow.ts(exporting<camelCase(name)>Workflow) is a workflow. stackbone.config.tsis an optional override — most workspaces need none. When present,defineWorkspace({ agents, workflows })wins over the scan:
// stackbone.config.ts — OPTIONAL; only when convention discovery isn't enough
import { defineWorkspace } from '@stackbone/sdk';
export default defineWorkspace({
agents: [{ name: 'support', dir: 'deep-agents/support' }],
workflows: [
{
name: 'onboarding',
module: 'workflows/onboarding.workflow.ts',
export: 'onboardingWorkflow',
},
],
});Authoring a deep agent
A deep agent is a single file — a model, an inline system prompt, and optional LangChain tools / sub-agents — built on LangChain's deepagents. The runtime builds the LangGraph graph in-process and serves it over the standard OpenAI + Anthropic chat wire; you write no HTTP code and no Dockerfile.
// deep-agents/support/index.ts
import { defineDeepAgent } from '@stackbone/sdk/deep';
// `defineDeepAgent` wraps `deepagents.createDeepAgent` with the Stackbone defaults:
// the managed OpenRouter model bridge (a bare model-id string is wrapped in a
// ChatOpenAI pointed at OpenRouter, with the org's sub-key injected at runtime as
// OPENROUTER_API_KEY) and a durable `stackbone.storage`-backed filesystem, so the
// agent's files survive across dev / cloud / self-host.
export default defineDeepAgent({
model: 'anthropic/claude-haiku-4.5',
systemPrompt: 'You are a support agent. Keep working notes in /notes.md.',
});Pass a built LangChain chat-model instance instead of a string to target another provider verbatim. Add
subagents, a custombackend, orinterruptOn(per-tool human-in-the-loop) to override a default.stackbone devdiscovers the agent by its folder (deep-agents/<name>/) and lets a client select it with"model": "support".
A tool is a plain LangChain tool() from @langchain/core/tools, listed in tools. Inside its body you reach the ambient stackbone client:
// deep-agents/support/index.ts
import { tool } from '@langchain/core/tools';
import { defineDeepAgent } from '@stackbone/sdk/deep';
import { stackbone, z } from '@stackbone/sdk';
import { escalations } from '../../schema';
const escalate = tool(
async ({ leadId, reason }: { leadId: string; reason: string }) => {
await stackbone.database.insert(escalations).values({ leadId, reason });
return JSON.stringify({ leadId, tagged: 'needs-human' });
},
{
name: 'escalate',
description: 'Escalate this lead to a human sales rep.',
schema: z.object({
leadId: z.string().describe('CRM contact id'),
reason: z.string().describe('Short reason for the hand-off'),
}),
},
);
export default defineDeepAgent({
model: 'anthropic/claude-haiku-4.5',
systemPrompt: 'You are a support agent. Escalate qualified leads with the escalate tool.',
tools: [escalate],
});To call a brokered third-party operation (Slack, Gmail, …) as a tool without hand-writing one, use
connectorTool({ connector, operation, schema })from@stackbone/sdk/deep— the SDK converts it to a real LangChain tool whose body runs through Stackbone Connect, so no token ever enters the container.
Browsing the web
To let a deep agent drive a real browser, add the browser toolset. browserTools() returns the five tools browser_goto (navigate), browser_observe (list the actions on the page), browser_act (do something in plain language), browser_extract (pull typed data) and browser_screenshot (capture the page). Each runs Stagehand over a real Chromium page the runtime pools per session. You can restrict where it goes with allowedDomains and pick a cheaper model for the on-page reasoning with browserTools({ model }).
There are two ways to attach the browser, and the difference matters:
browserSubagent()— the default. Attaches a ready-madebrowsersubagent in one line. The main agent delegates the whole navigation loop to it through deepagents' built-intaskmechanism; only the final result comes back. Two wins: the token-hungry back-and-forth stays in the subagent's own context (context isolation), and the subagent carries only the five browser tools, so nothing it reads off a web page can reach the main agent's connectors, files, or database (prompt-injection containment by construction). It shares the parent session's browser (same thread id), so delegating does not launch a second Chromium. PassbrowserSubagent({ model })to run the navigation loop on a cheaper/faster model than the main agent.import { defineDeepAgent, browserSubagent } from '@stackbone/sdk/deep'; export default defineDeepAgent({ model: 'anthropic/claude-sonnet-4.5', systemPrompt: 'You research topics. Delegate any web browsing to the browser subagent.', subagents: [browserSubagent({ model: 'anthropic/claude-haiku-4.5' })], });tools: [...browserTools()]— direct tools. Put the five tools on the main agent itself. Use this when the agent is essentially a browser agent (browsing is its job, so there is no context to isolate), or when you need fine-grained human-in-the-loop on individual browser actions — aninterruptOn: { browser_act: true }gate pauses the run for approval before that specific tool runs.import { defineDeepAgent, browserTools } from '@stackbone/sdk/deep'; export default defineDeepAgent({ model: 'anthropic/claude-sonnet-4.5', systemPrompt: 'You are a web navigator. Browse to complete the task.', tools: [...browserTools({ allowedDomains: ['example.com'] })], interruptOn: { browser_act: true }, // pause for approval before each action });
HITL works through the subagent too. A tool gated with
interruptOninside thebrowsersubagent still pauses the whole run for approval: deepagents runs the subagent with the parent's checkpointer, so the interrupt propagates up to the main graph and surfaces on the emulator's pause/resume exactly like a top-level pause (approving it withCommand(resume)re-drives the same thread and the subagent continues past the interrupt). So the rule of thumb — subagent by default, direct tools when the agent is basically a browser agent — is about context isolation and injection containment, not about whether approvals work.
Authoring a durable workflow
A workflow is a plain async function marked 'use workflow', with its side-effects in helpers marked 'use step'. Its public contract is sibling inputSchema / outputSchema exports — there is no defineWorkflow wrapper. Each 'use step' is a checkpoint: it runs once, is persisted, and resumes from the last completed step after a crash, so keep every step idempotent.
// workflows/onboarding.workflow.ts
import { stackbone, z } from '@stackbone/sdk';
import { welcomes } from '../agents/support/schema';
// THE contract — sibling exports the build harvests into the manifest + a validator.
export const inputSchema = z.object({
userId: z.string(),
email: z.email(),
plan: z.enum(['free', 'pro', 'scale']),
});
export const outputSchema = z.object({
userId: z.string(),
tips: z.array(z.string()),
});
export async function onboardingWorkflow(input: z.infer<typeof inputSchema>) {
'use workflow'; // cheap, deterministic glue — it replays on resume; do I/O in steps
const signup = await validateSignup(input);
const saved = await persistWelcome(signup.userId);
return saved;
}
async function validateSignup(input: z.infer<typeof inputSchema>) {
'use step';
if (!input.email.includes('@')) throw new Error(`Invalid email: ${input.email}`);
return { userId: input.userId, plan: input.plan };
}
async function persistWelcome(userId: string) {
'use step'; // idempotent side effect on userId
await stackbone.database.insert(welcomes).values({ userId });
return { userId, tips: ['Connect your inbox', 'Invite a teammate'] };
}The ambient stackbone client
import { stackbone } from '@stackbone/sdk' — the single handle for every platform surface, reachable from any tool execute() or workflow step. It resolves config from the injected environment lazily on first use; there is no createClient() in normal code and you never pass connection strings.
| Surface | What it is | Returns |
| -------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------- |
| stackbone.database | Native Drizzle ORM over the agent's Neon (pgvector, tsvector) | rows / throws (no { data, error }) |
| stackbone.storage | File uploads / downloads / signed URLs (R2 in prod, MinIO in dev) | { data, error } |
| stackbone.ai | LLM chat / embeddings / vision via OpenRouter (300+ models); stream long completions | { data, error } |
| stackbone.rag | Ingest + hybrid (pgvector + tsvector) retrieval; tables are platform-provisioned | { data, error } |
| stackbone.config | Read dynamic config the operator edits in Studio: get(key) / getAll() | { data, error } |
| stackbone.secrets | Read operator-managed encrypted secrets: get(name) / getMany(names) | { data, error } |
| stackbone.prompts | Versioned prompt catalog: get() / compile(key, vars) / list() / create() | { data, error } |
| stackbone.connection(id) | Call a third-party connector by id (Stackbone Connect) | operation output / throws ConnectorCallError |
The one rule across every surface: destructure { data, error } and handle both branches. The only exceptions are stackbone.database (native Drizzle — returns rows, throws) and stackbone.connection (returns the operation output, throws ConnectorCallError).
Database
Native Drizzle — awaiting a query returns the typed rows and throws on error (handle with try/catch or let it bubble). Tables are pgTable objects from the workspace's schema.ts; the column builders and helpers (eq, and, sql, …) all come from @stackbone/sdk/db, so you never install drizzle-orm directly.
import { eq, desc } from '@stackbone/sdk/db';
import { leads } from '../../schema';
const rows = await stackbone.database
.select()
.from(leads)
.where(eq(leads.status, 'qualified'))
.orderBy(desc(leads.createdAt))
.limit(20); // a select() resolves to an ARRAY — const [first] = rows
const [created] = await stackbone.database
.insert(leads)
.values({ email: '[email protected]', status: 'new', score: 0 })
.returning();
await stackbone.database.transaction(async (tx) => {
await tx.update(leads).set({ status: 'contacted' }).where(eq(leads.id, created.id));
});Storage
Bucket-scoped via from(bucket); every key is transparently prefixed with the agent id so your code never thinks about multi-tenancy:
const { data, error } = await stackbone.storage
.from('uploads')
.upload('reports/2026-04-29.pdf', pdfBlob, { contentType: 'application/pdf' });
if (error) throw new Error(error.message);
// Prefer a signed URL over download() for large objects (download materialises the body).
const { data: url } = await stackbone.storage
.from('uploads')
.getSignedDownloadUrl('reports/2026-04-29.pdf');AI
OpenAI-compatible shape, routed through OpenRouter (300+ models, one managed sub-key). The envelope covers the handshake; once a stream is open, mid-flight errors surface through the iterator (try/catch your for await).
const { data, error } = await stackbone.ai.chat.completions.create({
model: 'anthropic/claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Score this lead: ...' }],
});
if (!error) console.log(data.choices[0]?.message.content);
const { data: emb } = await stackbone.ai.embeddings.create({
model: 'openai/text-embedding-3-small',
input: 'A red Spanish sword from the 11th century.',
});RAG
Flat ingest + hybrid retrieval over the platform-provisioned tables — no collections to create:
await stackbone.rag.ingest({
collection: 'docs',
content: markdown,
contentType: 'text/markdown',
});
const { data: hits } = await stackbone.rag.search({
collection: 'docs',
query: 'how does X work?',
topK: 5,
});For durable ingest of large documents, prefer
ingestDocuments()from@stackbone/sdk/workflow(runs the reservedrag-ingestworkflow).
Config, secrets & prompts
All three are { data, error }, read-only, and key-checked. config.schema.ts (a Zod object at the workspace root) generates types so stackbone.config.get('greeting') is typed and a typo is a compile error.
const { data: tone } = await stackbone.config.get('replyTone'); // typed from config.schema.ts
const { data: apiKey, error } = await stackbone.secrets.get('STRIPE_API_KEY');
const { data: body } = await stackbone.prompts.compile('welcome-email', { name: 'Jane' });Calling a sibling deep agent
From a workflow 'use step', run one turn of a sibling deep agent by name with callDeepAgent from @stackbone/sdk/workflow. It runs the agent's graph in-process (no HTTP, no HMAC) and resolves with the turn's plain-JSON { text } — a durable step output that replays on crash-resume:
import { callDeepAgent } from '@stackbone/sdk/workflow';
async function askSupportForTips(plan: string) {
'use step';
const { text } = await callDeepAgent(
'support',
`A customer joined the "${plan}" plan. Give up to 3 onboarding tips.`,
);
return text;
}
inputis a bare user-message string or a{ messages }history.namenarrows to your declared deep agents oncestackbone devgenerates.stackbone/*.d.ts. UsestreamDeepAgent(same signature) to forward the reply live onto the run's stream.
Calling a connector
Run a brokered third-party operation directly — no agent, no model, no secret in the container. The call returns the operation output and throws a ConnectorCallError on failure (match err.code, never instanceof):
async function sendMail(input: { to: string; subject: string; body: string }) {
'use step';
// Typed after `stackbone dev` generates .stackbone/connect.d.ts; equivalent to
// .call('sendMail', input). Use .call('chat.postMessage', args) for dotted ids.
return stackbone.connection('stub-mail').sendMail(input);
}Subpath entrypoints
Some surfaces ship behind subpaths so a tool-only workflow never loads the deep / workflow peers it doesn't use:
| Entrypoint | Key exports |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @stackbone/sdk/deep | defineDeepAgent (author a deep agent), connectorTool (a Connect operation as a LangChain tool), browserTools (the five browser tools) / browserSubagent (attach the browser behind a delegation boundary), StackboneStorageBackend (the durable filesystem backend) |
| @stackbone/sdk/workflow | requestApproval, callDeepAgent / streamDeepAgent (run a sibling deep agent from a step), the raw defineHook / sleep escape hatch, ingestDocuments (the trigger/schedule helpers moved to stackbone.workflows.* on the main barrel; the loose startWorkflow / scheduleWorkflow / … exports here are deprecated aliases) |
| @stackbone/sdk/connect | connect (author a connection with brokered auth) |
| @stackbone/sdk/db | Drizzle re-exports — column builders (pgTable, text, vector, …) and helpers (eq, and, sql, …) |
| @stackbone/sdk/db/testing | createTestDatabase (spin up a throwaway Postgres for deterministic schema tests) |
Triggering & scheduling workflows
From inside a workflow, start or schedule another workflow by name through the ambient stackbone.workflows surface (these bind on dispatch, so call them from a workflow body or step, not a tool):
import { stackbone } from '@stackbone/sdk';
const { runId } = await stackbone.workflows.start('reconcile', { invoiceId }); // fire-and-forget → its own run
const summary = await stackbone.workflows.startAndWait<Summary>('summarize', { docId }); // durably wait for the output
await stackbone.workflows.schedule('daily-digest', { scope: 'all' }, '0 9 * * *'); // dynamic cron, idempotent by namename is the *.workflow.ts convention name (narrowed to your declared workflows once stackbone dev generates .stackbone/workflows.d.ts); the input is validated against the target's inputSchema before anything is enqueued. There is no separate queue system — durable workflows are the unit of background and scheduled work. For schedules that ship with the workspace, prefer the declarative export const schedules = [{ cron, input }] next to the workflow. (The loose startWorkflow / startWorkflowAndWait / scheduleWorkflow / unschedule / listSchedules exports on @stackbone/sdk/workflow still work but are deprecated aliases of these stackbone.workflows.* members.)
Human-in-the-loop — requestApproval
A workflow that needs a person to decide pauses durably with requestApproval() from @stackbone/sdk/workflow. It writes a row the Studio inbox shows, then races the human decision against a timeout. Call it from the workflow body, never inside a 'use step' — the pause is a workflow primitive that suspends the run.
import { requestApproval } from '@stackbone/sdk/workflow';
export async function refundWorkflow(input: z.infer<typeof inputSchema>) {
'use workflow';
const decision = await requestApproval({
token: `refund-${input.orderId}`, // resume key — unique per approval in the run
topic: 'refund',
payload: { orderId: input.orderId, amount: input.amount },
title: 'Approve refund',
timeout: '24h', // ISO-8601 duration or ms
fallback: 'reject', // applied if the timeout wins the race
});
// status === 'approved' is the ONLY green light; gate the side-effect on it.
if (decision.status !== 'approved') {
return { orderId: input.orderId, refunded: false, decision: decision.status };
}
await performRefund(input.orderId, input.amount); // a non-idempotent step, gated
return { orderId: input.orderId, refunded: true, decision: decision.status };
}requestApproval resolves to { status: 'approved' | 'rejected', payload?, timedOut } — timedOut is true only when the fallback fired. A human resolves it from the Studio inbox or the stackbone hitl CLI.
Result Envelope and Error Handling
Every SDK method (except stackbone.database and stackbone.connection, which throw) returns the same discriminated union:
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:
const result = await stackbone.ai.chat.completions.create({ model, messages });
if (result.error) {
console.error(`[${result.error.code}] ${result.error.message}`, result.error.meta);
return; // or: throw new Error(result.error.message) to propagate
}
console.log(result.data.choices[0]?.message.content);throw to propagate a failure; branch on error.code to handle a known case and return instead. Never swallow the error branch. Each surface ships its own stable code prefix so you can pattern-match without parsing the message:
| Prefix | Source | Examples |
| ------------ | -------------------- | --------------------------------------------------------------------------------------------------- |
| ai_* | stackbone.ai | ai_unauthorized, ai_credits_exhausted, ai_rate_limited, ai_aborted |
| s3_* | stackbone.storage | s3_credentials_missing, s3_invalid_key, s3_error |
| rag_* | stackbone.rag | rag_invalid_request, rag_dim_mismatch, rag_embedding_failed, rag_error |
| approval_* | stackbone.approval | approval_invalid_request, approval_persist_failed, approval_not_found, approval_unavailable |
| secrets_* | stackbone.secrets | secrets_invalid_request, secrets_not_found, secrets_unavailable |
| config_* | stackbone.config | config_invalid_request, config_not_found, config_unavailable |
| prompts_* | stackbone.prompts | prompts_not_found, prompts_invalid_request, prompts_unavailable |
| http_* | transport-level | http_timeout, http_aborted, http_network_error — surface statuses remap to <surface>_* |
stackbone.database throws Postgres/Drizzle errors (no envelope); stackbone.connection throws a ConnectorCallError whose err.code follows the broker taxonomy (invalid_args, credential_error, timeout, ambiguous, unauthorized, unavailable).
The canonical inventory of every envelope code lives in
src/errors/codes.tsas a typed catalog (SdkErrorCode, exported from@stackbone/sdk). The compiler refuses any literalcodenot declared there; callisSdkErrorCode(raw)to widen a wire string back into the catalog.
TypeScript Support
The SDK is written in TypeScript and ships its own type definitions. The ambient client, the Result<T> envelope and the surface types are all typed end to end:
import { stackbone, z, type Result, type SdkError } from '@stackbone/sdk';
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-compatible layer — an author who already knows the OpenAI SDK does not have to relearn anything. stackbone.config.get(...), the workflow trigger names, and stackbone.connection(id).<operation> all narrow to your own workspace once stackbone dev has generated the .stackbone/*.d.ts ambient types.
Advanced escape hatch. A
createClient()is still exported for code that runs outside a tool/step (a script, a test, a one-off) or that must override config explicitly. In normal workspace code you never need it — reach for the ambientstackboneinstead.
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 signed runtime calls all assume a server-side runtime. For browser-side flows (uploads, public asset access), use the signed URLs returned by stackbone.storage and let the browser hit S3 directly. Observability needs no configuration: the runtime auto-instruments outbound calls and correlates console.* output with the right run.
Documentation
- Stackbone TECH_STACK — Normative stack and platform primitives
- ADR — SDK monolítico del agente — Why a single SDK, scope of every surface, error model
- Componente — SDK & Creator DX — End-to-end developer experience for 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
