@stackbone/sdk overview
The TypeScript SDK behind every Stackbone agent and workflow. One ambient client —
stackbone— for the agent's data plane, plus two focused subpaths:@stackbone/sdk/workflowfor durable orchestration and human-in-the-loop, and@stackbone/sdk/connectfor third-party connectors. Every read returns a uniformResult<T>envelope, and a capability handshake fails fast when the agent and the runtime drift apart.
Install
pnpm add @stackbone/sdkThe package is a thin convenience layer over the partner SDKs the runtime
provisions for you (Postgres + Drizzle, an
S3-compatible client, the Vercel AI SDK pointed
at OpenRouter, …). You should never have to add
those partner libraries to your package.json directly — the SDK's barrel
re-exports the symbols you need.
The product is durable agents and durable workflows. An
agent holds open-ended, multi-turn
conversations; a workflow runs a fixed,
crash-proof pipeline. Both reach the same data plane through the ambient
stackbone client below.
The ambient stackbone client
Inside a deep-agent tool or a workflow step you reach the data plane through
the ambient handle: stackbone, imported directly from the barrel. It is
the same process-scoped client the runtime would build for you, resolved
from the environment on first use, so there is no createClient() and no
credential wiring:
import { tool } from '@langchain/core/tools';
import { defineDeepAgent } from '@stackbone/sdk/deep';
import { stackbone, z } from '@stackbone/sdk';
const readTone = tool(
async () => {
const tone = await stackbone.config.get('tone');
return tone.error ? 'neutral' : tone.data;
},
{
name: 'read_tone',
description: "Return the agent's current tone setting.",
schema: z.object({}),
},
);
export default defineDeepAgent({
model: 'anthropic/claude-haiku-4.5',
systemPrompt: 'You help customers with their account settings.',
tools: [readTone],
});The same handle works verbatim from a workflow 'use step':
import { stackbone } from '@stackbone/sdk';
import { leads } from './schema';
async function countOpenLeads() {
'use step';
// Drizzle handle, S3 client, OpenRouter pool, etc. are built only when
// you actually touch the corresponding module.
const rows = await stackbone.database.select().from(leads);
return { count: rows.length };
}Every member is built lazily on first access and cached for the life of the
process — one client, one connection pool. No surface is constructed and no
env var is read at import time, so importing stackbone is cheap and
side-effect-free.
Reaching each member
Every capability reads the same way — stackbone.<noun>:
| Member | What it reaches |
|---|---|
stackbone.config |
Typed reads of dynamic per-agent config set in the dashboard. |
stackbone.secrets |
Agent-encrypted secrets registered in the dashboard. |
stackbone.database |
Drizzle handle bound to the agent's Postgres. |
stackbone.storage |
S3-compatible object storage with per-agent key prefixing. |
stackbone.ai |
OpenAI-compatible chat, embeddings, image generation, model catalogue. |
stackbone.rag |
Parse → chunk → embed → store → retrieve on top of stackbone.database. |
stackbone.prompts |
Versioned prompt catalog with Mustache-style compile, over the agent's Postgres. |
stackbone.approval |
The HITL inbox + LLM-tool wrapper (the in-agent surface, distinct from workflow gates). |
stackbone.connection(id) |
A Stackbone Connect connector by its verbatim id. |
stackbone.connection(id) is a plain signed fetch, it pulls no extra
dependency. To call a sibling agent, reach for callDeepAgent(name, input)
from @stackbone/sdk/workflow instead: it runs one turn of the named agent
in-process from inside a workflow step and resolves with { text }.
import { callDeepAgent } from '@stackbone/sdk/workflow';
async function askSupport(plan: string) {
'use step';
const { text } = await callDeepAgent('support', {
message: `A customer joined the "${plan}" plan. Give up to 3 onboarding tips.`,
});
return text;
}See Calling an agent from a workflow
for the full pattern, and defineDeepAgent if you
instead want one agent to delegate to a subagents entry within its own
graph.
Migrating? The bare top-level
connection(id)export (from@stackbone/sdk/workflowand@stackbone/sdk/connect) is now a deprecated alias forstackbone.connection(id). It still works unchanged, but new code should use the namespaced form so every capability reads asstackbone.<noun>.
Escape hatch — createClient(config?)
createClient(config?) returns a StackboneClient and is the same factory
the runtime uses under the hood. Reach for it only when you need a client
outside a tool or step (a one-off script, a test) or when you want to
override a piece of config the runtime injected. Every member is built
lazily on first access, so createClient() itself is cheap and
side-effect-free.
import { createClient } from '@stackbone/sdk';
const sb = createClient({ openrouterKey: process.env['MY_OWN_KEY'] });
const rows = await sb.database.select().from(leads);config is fully optional. Each field falls back to a documented env var the
runtime injects when omitted; the full shape lives in ClientConfig. The
runtime sets DATABASE_URL, OPENROUTER_API_KEY, OPENROUTER_BASE_URL,
STACKBONE_INSTALLATION_ID, AGENT_ID, and the storage credentials for
you, so in normal agent code you never construct config by hand.
Authoring workflows — @stackbone/sdk/workflow
A durable workflow is a plain async function
marked 'use workflow' that orchestrates idempotent 'use step' units. It
runs on the upstream Workflow SDK (the same
engine behind Vercel Workflows), so a
run survives crashes and redeploys and can pause for months.
You don't register workflows by hand. The workspace is discovered by
convention: stackbone dev and stackbone publish scan your project and
pick up every agent folder under deep-agents/ that has an index.ts, and
every workflow file at workflows/<name>.workflow.ts. A workflow's name is
the file basename without the .workflow.ts suffix, and its exported
function is the camel-cased name plus Workflow (so
workflows/refund.workflow.ts exports refundWorkflow). Scaffold one with
stackbone add workflow refund.
For the rare case where you need to override the workflow scan — point at a
different file or rename an export — drop an optional stackbone.config.ts
that default-exports defineWorkspace(...). When present its workflows
list wins over the convention scan; when absent (the common case) you need no
config file at all. Agents are always discovered from deep-agents/, the
config format does not declare them:
import { defineWorkspace } from '@stackbone/sdk';
export default defineWorkspace({
workflows: [{ name: 'refund', module: 'workflows/refund.workflow.ts', export: 'refundWorkflow' }],
});Hide workflows and agents from Studio — $internal
Some workflows and agents are infrastructure that should not appear in the
Studio/Playground UI. Mark them with $internal: true to exclude them from
the discovery manifest:
import { defineWorkspace } from '@stackbone/sdk';
export default defineWorkspace({
workflows: [
{
name: 'data-sync',
module: 'workflows/data-sync.workflow.ts',
export: 'dataSyncWorkflow',
$internal: true, // ← hidden from Studio/Playground
},
{ name: 'refund', module: 'workflows/refund.workflow.ts', export: 'refundWorkflow' },
],
deepAgents: [
{
name: 'maintenance-bot',
dir: 'deep-agents/maintenance-bot',
$internal: true, // ← hidden from Studio/Playground
},
],
});System workflows and agents are marked internal automatically — rag-ingest
is hidden by default without any configuration. Override this with
$internal: false if you need it visible.
Start another workflow by name, and manage cron schedules, through the ambient
stackbone.workflows surface: stackbone.workflows.start(name, input) /
.startAndWait(name, input) for triggers, and .schedule / .unschedule /
.listSchedules for cron — see
Triggering a workflow and
Background jobs & workflow triggers. The
@stackbone/sdk/workflow subpath carries the pause helpers that need the
workflow peer — requestApproval() below, plus the raw defineHook / sleep
escape hatch. (The same trigger/schedule helpers used to be loose imports from
that subpath — startWorkflow, scheduleWorkflow, and so on; those still work
but are deprecated aliases of the stackbone.workflows.* members.)
requestApproval() — human-in-the-loop
requestApproval() is the default HITL gate. The workflow body calls it to
pause durably on a hook, records an inbox row, and races the human
decision against a timeout — applying a fallback if nobody decides. Import
it from @stackbone/sdk/workflow, not the main barrel:
import { z } from '@stackbone/sdk';
import { requestApproval } from '@stackbone/sdk/workflow';
export async function refundWorkflow(input: { orderId: string; amount: number }) {
'use workflow';
const decision = await requestApproval({
token: `refund-${input.orderId}`,
topic: 'refund',
payload: { orderId: input.orderId, amount: input.amount },
title: 'Approve refund',
timeout: '24h',
fallback: 'reject',
});
if (decision.status !== 'approved') {
return { refunded: false, decision: decision.status };
}
await performRefund(input.orderId, input.amount);
return { refunded: true, decision: decision.status };
}
async function performRefund(orderId: string, amount: number) {
'use step'; // runs once, persisted, retried on failure — keep it idempotent
// …
}The decision resolves to { status: 'approved' | 'rejected', payload?, timedOut };
gate the side-effect on status === 'approved'. Hard rule:
requestApproval (and the raw defineHook below) must run in the workflow
body, never inside a 'use step' — .create() is a workflow primitive
that suspends the run, so the I/O lives in steps and the gate lives in the
body.
For advanced cases — a custom hook schema, several gates, escalation with a
timer — the subpath also re-exports defineHook and sleep verbatim from
the upstream workflow package as an escape hatch.
Calling a connector from a step
The subpath also exports callConnector() so a step can run one connector
operation with no agent in the loop. The namespaced
stackbone.connection(id) form (below) is the preferred way to do the same
thing.
Connectors — @stackbone/sdk/connect
Stackbone Connect is the current connector model. The operator installs a connection's credentials once in Studio; a broker mints install-scoped, short-lived tokens, so the agent code never sees a provider secret. Call a connector by id:
async function notify(input: { to: string; subject: string; body: string }) {
'use step';
// Typed from the connector's schema, generated into .stackbone/connect.d.ts
// by `stackbone dev`. `.call('operation', args)` is the dynamic escape hatch.
const output = await stackbone.connection('stub-mail').sendMail({
to: input.to,
subject: input.subject,
body: input.body,
});
return { sent: output.accepted === true, id: output.id };
}The @stackbone/sdk/connect subpath carries the lower-level building blocks
for wiring broker auth into a connection definition yourself instead of
calling through stackbone.connection(id): connect(), withConnect(),
connectHeaders(), callConnector(), and the
ConnectionAuthorizationRequiredError / ConnectionAuthorizationFailedError
classes (match them by err.name, never instanceof). See
Connections for the full picture.
Peer isolation
The main @stackbone/sdk barrel pulls no upstream peer at import time.
The two families of upstream packages it can lean on, deepagents /
@langchain/* (agent authoring) and workflow (durable execution), are
declared as optional peerDependencies, and anything that needs them
lives behind a subpath:
@stackbone/sdk/deepstatically importsdeepagents, and lazily imports@langchain/openai/@langchain/coreonly when it needs them.@stackbone/sdk/workflowstatically importsworkflow.
So a tool-only workflow project that imports only @stackbone/sdk never
eager-loads, or crash-loops on, a peer it never installed. @stackbone/sdk/connect
needs no external peer at all: the connection-auth vocabulary is owned by the
SDK itself.
The Result<T> envelope
Every method on every ambient member returns a Result<T>:
import type { Result, SdkError } from '@stackbone/sdk';
type Result<T> = { data: T; error: null } | { data: null; error: SdkError };error.code is constrained to the typed SdkErrorCode union — every literal
the SDK can emit lives in a single catalog, so a switch over
result.error.code gets exhaustiveness checking at compile time:
import { type SdkErrorCode, isSdkErrorCode } from '@stackbone/sdk';
function describe(code: SdkErrorCode): string {
switch (code) {
case 'secrets_not_found':
return 'Set the secret in the dashboard.';
case 'config_not_found':
return 'Set the config value in the dashboard.';
case 'contract_unreachable':
return 'The runtime did not answer the contract handshake.';
default:
return 'Unexpected error.';
}
}
// `isSdkErrorCode` narrows an arbitrary string (e.g. a code that arrived over
// the wire) to `SdkErrorCode` for safe re-emission.
if (isSdkErrorCode(rawCode)) {
describe(rawCode);
}Narrowing on result.error refines result.data to the success payload. The
SDK never throws for expected failure modes — auth, validation, missing
config, contract drift, partner errors all surface through error.code. The
one deliberate exception is stackbone.database: its query-builder verbs
return Drizzle's native chainable types (typed rows, not envelopes), and a
contract-gate failure throws a tagged Error instead of swallowing the
signature.
The contract handshake
The runtime exposes a contract describing the protocol version it speaks and the set of capabilities it advertises:
{
"version": 10,
"minSupported": 1,
"capabilities": [
"database.postgres_direct",
"rag.basic",
"queues.jobs",
"secrets.read_write",
"config.read_write",
"prompts.basic",
"approval.fire_and_forget",
"connections.actions",
"storage.s3",
"ai.openrouter"
]
}The SDK fires this handshake lazily on the first gated member call, caches
it for the process lifetime, and reuses it for every subsequent call. You can
inspect the last resolved contract synchronously through client.contract
(null until at least one gated call has resolved; it never fetches and never
throws).
Capability gating
Each gated member declares the single capability its surface depends on
(stackbone.database → database.postgres_direct, stackbone.rag →
rag.basic, stackbone.storage → storage.s3, and so on). Before
forwarding a call, the member awaits the handshake and asserts the capability
is advertised. If it is not, the call short-circuits with a stable error code:
contract_version_unsupported— the negotiated version is below the SDK's hard floor or your declared floor, whichever is higher.capability_unavailable— the version check passes but the runtime does not advertise the capability the member needs.
When the handshake itself cannot complete (network error, 404, malformed
body) the call surfaces contract_unreachable or contract_malformed
instead — these are always hard errors because the SDK genuinely cannot
tell what it is talking to.
Escape hatch — STACKBONE_REQUIRE_CONTRACT=0
For migrations and local debugging, set STACKBONE_REQUIRE_CONTRACT=0 to
suppress the gate. The handshake still runs and client.contract is still
populated, but capability/version errors are downgraded to a one-shot stderr
warning and the call is allowed through. Reachability errors are never
suppressed. Production agents should leave this flag unset.
| Env var | Default | Meaning |
|---|---|---|
STACKBONE_REQUIRE_CONTRACT |
1 (gating on) |
Set to 0 to suppress capability/version errors (warning instead). |
STACKBONE_CONTRACT_TTL_MS |
unset (process) | Re-fetch the handshake after this many milliseconds. Default is no TTL. |
STACKBONE_DEBUG |
unset | Set to 1 to log a one-line handshake-resolved message. |
Where to go next
- Agents: the deep-agent authoring model, and
callDeepAgent()for reaching a sibling agent from a workflow step. - Durable workflows —
'use workflow'/'use step',requestApproval,defineWorkspace, and triggering runs. - Stackbone Connect — the broker model and
stackbone.connection(id). - The data-plane surfaces —
stackbone.database,stackbone.rag,stackbone.storage,stackbone.ai,stackbone.config,stackbone.secrets,stackbone.prompts,stackbone.approval, connectors. - Background jobs & workflow triggers — model background and recurring work as durable workflow runs started by name.
- Logging & observability — the run timeline is captured for you.
- CLI reference —
stackbone dev,stackbone publish,stackbone workflows,stackbone runs,stackbone hitl.