@stackbone/sdk overview

The TypeScript SDK behind every Stackbone agent and workflow. One ambient clientstackbone — 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 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/workflow and @stackbone/sdk/connect) is now a deprecated alias for stackbone.connection(id). It still works unchanged, but new code should use the namespaced form so every capability reads as stackbone.<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/deep statically imports deepagents, and lazily imports @langchain/openai / @langchain/core only when it needs them.
  • @stackbone/sdk/workflow statically imports workflow.

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

The platform for agent developers.Build, ship and observe agentsthat survive prod.

© 2026 · STACKBONEBUILT WITH ❤️ FROM CANADA AND SPAIN