@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 clients it carries as its
own dependencies: Postgres + Drizzle, an
S3-compatible client, and an OpenAI-compatible client pointed at
OpenRouter. You do not add any of those to
your package.json, and @stackbone/sdk/db re-exports Drizzle's query
helpers so you import them from one place. You do install the optional
peers the authoring subpaths need: see Peer isolation.
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 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({
name: 'support',
model: 'anthropic/claude-haiku-4.5',
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 touch the corresponding module.
const rows = await stackbone.database.select().from(leads);
return { count: rows.length };
}The SDK builds each member on first access and caches it for the life of the
process: one client, one connection pool. Importing stackbone builds no
surface and reads no env var, so the import stays cheap and free of side
effects.
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: use(key, vars) returns the text; get / compile / list. |
stackbone.settings |
Read-only workspace knobs an operator picks in the dashboard. |
stackbone.approval |
The HITL inbox + LLM-tool wrapper (the in-agent surface, distinct from workflow gates). |
stackbone.workflows |
Start another workflow by name, and manage its cron triggers. |
stackbone.connection(id) |
A Stackbone Connect connector by its verbatim id. |
stackbone.connection(id) is a plain signed fetch and pulls no extra
dependency. To call a sibling agent, use 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',
`A customer joined the "${plan}" plan. Give up to 3 onboarding tips.`,
);
return text;
}input is a non-empty string, or a { messages: [{ role, content }] } history
when the agent needs more than one turn of context.
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 from an older SDK: the bare top-level
connection(id)export on@stackbone/sdk/connectis now a deprecated alias forstackbone.connection(id), and the copy that used to ship from@stackbone/sdk/workflowis gone. The alias still works unchanged, but new code should use the namespaced form so every capability reads asstackbone.<noun>.
Knowing which run you are in
getInvocationContext() returns the invocation your code is running inside, or
undefined when there is none. Use it to correlate your own telemetry with the
run the dashboard shows:
import { getInvocationContext } from '@stackbone/sdk';
async function chargeCustomer() {
'use step';
const ctx = getInvocationContext();
console.log('run', ctx?.runId, 'step', ctx?.activeStep?.stepName);
}activeStep names the workflow step whose body is running: { stepId, stepName?, attempt }.
It is undefined for a direct invoke and for an agent running outside a
workflow. The field resolves on every read, so read it where you need it instead
of copying the context object.
Escape hatch: createClient(config?)
createClient(config?) returns a StackboneClient and is the same factory
the runtime uses internally. Use 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. The client builds each
member on first access, so createClient() itself is cheap and
side-effect-free.
import { createClient } from '@stackbone/sdk';
const sb = createClient({ modelProviderKey: process.env['MY_OWN_KEY'] });
const rows = await sb.database.select().from(leads);config is optional. Each field falls back to a documented env var the
runtime injects when omitted; the full shape lives in ClientConfig. The
runtime sets STACKBONE_POSTGRES_URL, MODEL_PROVIDER_API_KEY,
MODEL_PROVIDER_BASE_URL, STACKBONE_INSTALLATION_ID, STACKBONE_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. stackbone dev and the deployed
runtime discover the workspace by convention: they 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. Deep agents are still discovered from deep-agents/
unless you list them explicitly (see $internal below):
import { defineWorkspace } from '@stackbone/sdk';
export default defineWorkspace({
agents: [], // required, and empty in a deep-agent workspace
workflows: [{ name: 'refund', module: 'workflows/refund.workflow.ts', export: 'refundWorkflow' }],
});agents and workflows are both required fields, so keep agents: [] even
when you only override workflows.
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({
agents: [],
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' },
],
// Listing `deepAgents` replaces the `deep-agents/` scan: name every agent
// you want the runtime to see, not only the hidden one.
deepAgents: [
{
name: 'maintenance-bot',
dir: 'deep-agents/maintenance-bot',
$internal: true, // ← hidden from Studio/Playground
},
],
});The runtime marks system workflows and agents internal for you: rag-ingest
stays hidden 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.
Migrating from an older SDK: earlier versions exposed the trigger and schedule helpers as loose top-level imports from
@stackbone/sdk/workflow(startWorkflow,startWorkflowAndWait,scheduleWorkflow,unschedule,listSchedules). Those exports are gone. Use the namespacedstackbone.workflows.*form above instead.
requestApproval(): human-in-the-loop
requestApproval() is the default HITL gate. The workflow body calls it to
pause durably on a hook; the call 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. It re-exports
FatalError from the same package too: throw it from a 'use step' to fail
the run immediately on a permanent condition (a missing secret, an invalid
input) instead of burning the step's retry budget.
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. Prefer the namespaced
stackbone.connection(id) form (below) for the same job.
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 SDK declares the two families of upstream packages it can use,
deepagents / @langchain/* (agent authoring) and workflow (durable
execution), 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.
A tool-only workflow project that imports only @stackbone/sdk therefore never
eager-loads, or crash-loops on, a peer it never installed. @stackbone/sdk/connect
needs no external peer at all: the SDK owns the connection-auth vocabulary
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 does not throw for expected failure modes: auth, validation, missing
config, contract drift and 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": 15,
"minSupported": 1,
"capabilities": [
"database.postgres_direct",
"rag.basic",
"queues.jobs",
"secrets.read_write",
"config.read_write",
"approval.fire_and_forget",
"storage.s3",
"ai.openrouter",
"prompts.basic",
"browser.provider"
],
"build": { "name": "stackbone-cli", "version": "0.3.3" }
}That list is what a workspace runtime (stackbone dev and the deployed image
alike) advertises today. Read it rather than assuming it. A runtime lists only
a capability whose route it serves, so the set tells you what you can call.
build names the runtime that answered and the version it was built from.
The SDK fires this handshake 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 stackbone.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 checks that the runtime
advertises the capability. If it does 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 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 still populates
client.contract, but the SDK downgrades capability/version errors to a
one-shot stderr warning and lets the call 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.settings,stackbone.prompts,stackbone.approval, connectors. - Background jobs & workflow triggers: model background and recurring work as durable workflow runs started by name.
- Guardrails: operator-configured rules the runtime enforces at the chat and workflow borders. No SDK surface: you write no code for them, but they can block, mask or hold what reaches your agent.
- Logging & observability: the runtime captures the run timeline for you.
- CLI reference:
stackbone dev,stackbone link,stackbone workflows,stackbone runs,stackbone hitl.