@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/workflow for durable orchestration and human-in-the-loop, and @stackbone/sdk/connect for third-party connectors. Every read returns a uniform Result<T> envelope, and a capability handshake fails fast when the agent and the runtime drift apart.

Install

pnpm add @stackbone/sdk

The 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/connect is now a deprecated alias for stackbone.connection(id), and the copy that used to ship from @stackbone/sdk/workflow is gone. The alias still works unchanged, but new code should use the namespaced form so every capability reads as stackbone.<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 namespaced stackbone.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/deep statically imports deepagents, and lazily imports @langchain/openai / @langchain/core only when it needs them.
  • @stackbone/sdk/workflow statically imports workflow.

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.databasedatabase.postgres_direct, stackbone.ragrag.basic, stackbone.storagestorage.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

BUILT WITH ❤️ FROM CANADA AND SPAIN