stackbone.connection

The credential never enters your agent container. The operator registers a connector and its credentials once in Studio. Your code reaches that connector through the Stackbone Connect broker, which mints a short-lived scoped token at call time and runs the action on your behalf. Your code only ever sees the connector id and the operation output, never a token or key.

A connector is a typed integration with a third party (Gmail, Linear, GitHub, an internal OpenAPI service). Stackbone Connect is the way your agents and workflows act on those connectors. There are three entrypoints, each on its own import. The one you use depends on where you call from:

You are in… Use Import from
an agent tool stackbone.connection(id) @stackbone/sdk
a durable workflow step callConnector(...) (or stackbone.connection(id)) @stackbone/sdk/workflow
a connection runtime's auth config connect() / withConnect() / connectHeaders() @stackbone/sdk/connect

The first two are imperative "call this operation now" handles. The third wires a connector's auth into a connection definition your own tool code builds (an MCP client, a generated OpenAPI client), so its calls reach the provider without you passing each call through the broker yourself. All three hit the same broker over the same HMAC-signed transport, so pick the one that matches your call site.

For the broker model and how an operator installs credentials, see Concepts → Stackbone Connect.

Call a connector from your code: stackbone.connection(id)

The ambient stackbone client carries a connection(id) accessor. Select a connector by its verbatim id (the same id the operator gave it in Studio) and you get a handle whose operations you can call:

import { tool } from '@langchain/core/tools';
import { stackbone, z } from '@stackbone/sdk';

const notifyOps = tool(
  async ({ raw }: { raw: string }) => {
    // Gmail's operation ids are dotted, so they are not JS identifiers: call one
    // through `.call(operation, args)`, which is always available.
    await stackbone.connection('gmail').call('gmail.users.messages.send', {
      userId: 'me',
      requestBody: { raw },
    });

    // An operation whose id IS a plain identifier can be called directly, and
    // is fully typed once `stackbone dev` has generated your connector types:
    await stackbone.connection('stub-mail').sendMail({ to: 'ops@acme.com', subject: 'ping' });

    return JSON.stringify({ sent: true });
  },
  {
    name: 'notify_ops',
    description: 'Email the ops inbox.',
    schema: z.object({ raw: z.string() }),
  },
);

Two things to know about this handle:

  • Operations become typed once stackbone dev has generated your workspace's connector types (written to .stackbone/connect.d.ts). With types in place a typo becomes a compile error and your editor autocompletes the real operations. Until then (or for an operation id that is not a valid JS identifier, like a dotted chat.postMessage), use the always-present .call(operation, args) escape hatch.
  • It returns the operation output as plain JSON (Promise<unknown>), so narrow it yourself. There is no Result envelope on this path: on failure it throws (see Errors).

This handle is a plain HMAC-signed call to the broker. It pulls in no extra dependency, so a project can import @stackbone/sdk and call connectors without installing the agent-authoring or Workflow SDK peers.

Call a connector from a workflow step: callConnector()

Inside a durable workflow, a 'use step' often needs to do one provider action (send a Telegram message, read a file from GitHub) with no agent in the loop. callConnector() is that direct path. Import it from @stackbone/sdk/workflow:

import { callConnector } from '@stackbone/sdk/workflow';

async function notifyTeam(orderId: string) {
  'use step'; // runs once, persisted, retried on failure. Keep it idempotent
  await callConnector('telegram', 'send-message', {
    chat_id: '123456',
    text: `New order ${orderId}`,
  });
}

callConnector(connector, operation, args?, opts?) POSTs the action to the broker and returns the operation output as plain JSON (Promise<unknown>). It throws a ConnectorCallError on failure: same contract as stackbone.connection(id).call(...), which is sugar over it.

The optional opts.principal chooses who the broker makes the call on behalf of. It defaults to { type: 'app' }: the agent's own service-account credential, which almost every call should use:

// Explicit, equivalent to the default:
await callConnector('telegram', 'send-message', args, { principal: { type: 'app' } });

The principal can also be user, client-credentials, or jwt-bearer for the broker's machine-to-machine modes; the user principal scopes the broker's token issuer to a specific end user. App-scoped is the path you want for an agent acting as itself.

Workflows and durable steps are described in Concepts → Workflows and the Workflow SDK docs.

Wire broker auth into a connection runtime: connect()

The two handles above call a connector imperatively. The other model applies when your tool code builds on its own connection-authoring library (an MCP client, a generated OpenAPI client) that probes an auth strategy before every call. Wire that strategy to the Stackbone Connect broker instead of holding a static credential. connect(connectorId) returns that auth strategy. Import it from @stackbone/sdk/connect:

deep-agents/support/connections/example.ts
import { defineOpenAPIConnection } from '<your connection handler>';
import { connect } from '@stackbone/sdk/connect';

export default defineOpenAPIConnection({
  spec: 'https://api.example.com/openapi.json',
  auth: connect('example'), // broker mints a short-lived bearer at call time
});

The connection runtime probes auth before every call; connect() fetches a fresh short-lived bearer from the broker each time, so a rotated credential takes effect on the next call and your code holds nothing static. The returned strategy is a plain { getToken, evict } shape and drops into any connection factory that accepts one, for example defineMcpClientConnection({ auth: connect('…') }).

Two helpers cover the common variations:

  • withConnect(definition, connectorId) bolts broker auth onto an existing connection definition without re-typing the auth field:

    import { withConnect } from '@stackbone/sdk/connect';
    export default withConnect(
      defineOpenAPIConnection({ spec: 'https://api.example.com/openapi.json' }),
      'example',
    );
  • connectHeaders(connectorId) is for a credential that does not go in as a Bearer token. A connection runtime's auth carries only a Bearer; anything else rides the connection's separate headers closure, and connectHeaders produces that closure, pre-wired to the broker:

    import { connectHeaders } from '@stackbone/sdk/connect';
    export default defineOpenAPIConnection({
      spec: 'https://api.example.com/openapi.json',
      headers: connectHeaders('example'),
    });

    Where the token goes is the broker's answer, not yours. The mint carries the placement the operator configured in Studio, and every slot gets the same token on the same request: a bearer placement becomes Authorization: Bearer …, a named header gets the raw token with no scheme, and a credential registered in two slots at once gets both. So connectHeaders('linear') sends a bare Authorization, without you naming the header. That is what Linear wants: it answers a Bearer prefix with a 400.

    The optional second argument is a fallback, not an override: it is used only when the box is old enough not to report its placement set, and it defaults to X-Api-Key. A credential placed in a query parameter cannot be a header at all, so it throws ConnectionAuthorizationFailedError with reason: 'placement_unsupported' rather than sending the request unauthenticated. Call that connector through stackbone.connection(id).call(…) instead, where the box applies the placement server-side.

    connect() and withConnect() are Bearer-only and cannot honour a placement: TokenResult has no field for one, so the runtime prepends Bearer unconditionally. Wire a non-Bearer connector through connectHeaders() or stackbone.connection(id).

Scope. connect() resolves the agent's own (app-scoped) credential: the operator completes the connector's OAuth setup once in Studio and the broker hands the token over at call time. Interactive, per-user OAuth is a future phase; today this path is app-scoped and non-interactive.

When the connector isn't ready

If the operator hasn't finished installing a connector's credentials in Studio, connect() (and the helpers built on it) signals it with a typed error so you can branch instead of failing blind. Both are re-exported from @stackbone/sdk/connect:

  • ConnectionAuthorizationRequiredError: the credential needs to be authorized out of band (the operator must finish the OAuth dance in Studio).
  • ConnectionAuthorizationFailedError: a terminal credential failure (for example, the connector was never installed).

Match these by err.name, never instanceof: under bundling your code and the SDK can end up with different class identities, so the name is the only stable signal:

try {
  await stackbone.connection('example').call('invoices.get', { id: 'inv_123' });
} catch (err) {
  if (err instanceof Error && err.name === 'ConnectionAuthorizationRequiredError') {
    // Tell the operator to finish connecting the integration in Studio.
  }
}

Errors

The direct-call path (stackbone.connection(id) and callConnector()) does not return a Result envelope. On failure it throws a ConnectorCallError: an Error carrying a machine-readable .code from the broker. Match on the code string, never instanceof (the same dual-instance hazard applies):

try {
  await stackbone.connection('gmail').call('gmail.users.messages.send', { userId: 'me' });
} catch (err) {
  const code = err instanceof Error ? (err as { code?: string }).code : undefined;
  if (code === 'connector_installation_required') {
    // Operator hasn't installed this connector's credentials yet.
  }
}

Common codes: invalid_args, credential_error, no_token, timeout, connector_installation_required, execute_failed, invalid_output.

What the runtime provides

You never sign or address the broker yourself. The runtime does it:

  • It injects the broker base URL and the install id on the agent / workflow process. It HMAC-signs every connector call and scopes it to your install.
  • The broker resolves the credential server-side, runs the action, and returns only the output: the credential never enters your container.
  • A call made while the runtime is running your code carries the id of the run that made it. You pass nothing. It is what lets an eval suite point a connector at a stand-in for its own runs while live traffic in the same organization keeps reaching the real provider.
  • Running outside a Stackbone runtime (no broker URL or install id on the process) throws an error telling you to run the agent through stackbone dev.

The routes and the signature scheme are an implementation detail of the broker.

Receiving connector events

Calling an operation is the outbound half. The inbound half, reacting when a connector fires (a message arrives, a new email lands), does not go through these handles. The operator wires a connector trigger to your workspace in Studio; when it fires, it starts a durable workflow run (or an agent session), which then does its work and can call connectors back out. There is no synchronous handler to import.

To develop a trigger-driven workflow locally without a real account, start the workflow directly by name with stackbone workflows start.

Migrating from the bare connection(id) export

The bare top-level connection(id) export is gone from @stackbone/sdk/workflow. Import the ambient client and call stackbone.connection(id) instead. On @stackbone/sdk/connect it survives as a deprecated alias that behaves the same. Prefer the namespaced form so every SDK capability reads as stackbone.<noun>.

Where to go next

BUILT WITH ❤️ FROM CANADA AND SPAIN