Stackbone Connect

Stackbone Connect is the connector layer. The operator registers a connector and its credentials once in Studio; your code reaches the provider by name and the broker mints a short-lived scoped token at call time. The real key never enters your agent container — your code only ever names a connector and an operation.

A connector is a typed integration with a third-party API: Slack, GitHub, Gmail, a stub mail provider. The credential to call it — an API key, an OAuth token — is something you do not want in your prompt, in your source, or in the agent's environment. Connect solves that by putting a broker between your code and the provider:

your code ──names connector + operation──▶ Stackbone Connect broker
                                             ├─ holds the real credential
                                             ├─ mints a short-lived scoped token
                                             └─ performs the call, returns the output

There are three ways to reach a connector, and they cover the kinds of caller in the new runtime:

Caller Surface Import from
A workflow step or an agent tool stackbone.connection(id) @stackbone/sdk
A workflow step, no agent in the loop callConnector(...) @stackbone/sdk/workflow
A connection runtime's auth config connect() / withConnect() / connectHeaders() @stackbone/sdk/connect

The first two are imperative: you name an operation and get its output. The third is declarative: you wire a connection's auth so the runtime resolves a token transparently before every call. Connectors are a Stackbone Connect concept: the broker supplies the credentials so your tools never hold a key.

Call a connector — stackbone.connection(id)

stackbone.connection(id) is the primary, typed accessor. Select a connector by its verbatim id, then call an operation on it:

workflows/send-mail.workflow.ts
import { z, stackbone } from '@stackbone/sdk';

export const inputSchema = z.object({
  to: z.string(),
  subject: z.string(),
  body: z.string(),
});
export const outputSchema = z.object({ sent: z.boolean(), id: z.string() });

export async function sendMailWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';
  return await sendViaConnector(input);
}

async function sendViaConnector(input: z.infer<typeof inputSchema>) {
  'use step';
  // Typed from the connector's schema — see "Typed operations" below.
  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 handle returns the operation's output as plain JSON — narrow it yourself. There is no Result envelope on this path: on failure it throws (see Errors). The call is HMAC-signed for you against the broker; you never handle a token.

Typed operations

When you run stackbone dev, the CLI introspects each connector's schema and writes typed operation maps into .stackbone/connect.d.ts in your project. So stackbone.connection('stub-mail').sendMail({ ... }) becomes fully typed — its arguments and its return value — derived from the connector's OpenAPI (or tool) schema.

The dynamic escape hatch — .call(operation, args)

Every handle also carries .call(operation, args), always available, no generated types required. Reach for it when the operation id is not a JS identifier (a dotted id like chat.postMessage) or when a connector has no generated types yet:

'use step';
await stackbone.connection('slack').call('chat.postMessage', {
  channel: 'C123',
  text: 'Deploy finished.',
});

The typed form and .call(...) hit the same broker call — .sendMail(args) is exactly .call('sendMail', args) at runtime.

Call a connector with no agent — callConnector()

callConnector(connector, operation, args?, opts?) is the explicit form of the same call. Import it from @stackbone/sdk/workflow and use it directly inside a durable workflow step when no agent and no connection runtime sit in the middle:

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

async function postToSlack(channel: string, text: string) {
  'use step';
  const result = await callConnector('slack', 'chat.postMessage', { channel, text });
  return result;
}

stackbone.connection('slack').call('chat.postMessage', args) and callConnector('slack', 'chat.postMessage', args) are the same operation — the ambient handle is sugar over callConnector. Both are a plain HMAC-signed broker call; neither goes through any contract handshake.

Who the call is made on behalf of — principal

The optional fourth argument carries { principal }. It defaults to { type: 'app' } — the agent's own service-account credential, the credential the operator installed. The other principal modes scope the broker token to a different identity:

Principal Meaning
{ type: 'app' } Default. The agent's own service-account credential.
{ type: 'user', id, issuer } Scope the token to a specific end user.
{ type: 'client-credentials', serviceAccountId } Machine-to-machine.
{ type: 'jwt-bearer', subject, issuer } Machine-to-machine via a bearer assertion.

For most agents the default app principal is all you need.

Wire broker auth into a connection runtime — connect()

The two handles above call a connector imperatively. This third model applies when your tool code is built on a connection-authoring library of its own (an MCP client, a generated OpenAPI client) that probes an auth strategy before every call. Wire that strategy to the broker instead of holding a static credential: connect(), withConnect(), and connectHeaders() do that, all from @stackbone/sdk/connect.

To call a connector as a plain agent tool, you usually don't need this. Use connectorTool({ connector, operation, schema }) from @stackbone/sdk/deep. It builds a LangChain tool whose body runs through the broker, so no token ever enters your container. Reach for connect() only when you have your own connection runtime to authenticate.

A connection lives in deep-agents/<name>/connections/<slug>.ts. Drop connect(connectorId) into its auth field:

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'),
});

The connection runtime probes auth before each call: connect() fetches a short-lived bearer from the broker (HMAC-signed, install-scoped) and applies it as Authorization: Bearer. The operator registered the connector's credentials once in Studio; your tools now reach the provider transparently.

withConnect(definition, connectorId) is the bolt-on form: build the connection first, then attach broker auth without re-typing the auth field:

import { defineMcpClientConnection } from '<your connection handler>';
import { withConnect } from '@stackbone/sdk/connect';

export default withConnect(
  defineMcpClientConnection({ url: 'https://mcp.example.com' }),
  'example',
);

A connection's auth carries only a Bearer token. For a provider that wants the credential in a non-Bearer header (an API key under X-Api-Key), connectHeaders(connectorId, headerName?) produces the header closure instead. It defaults to X-Api-Key and re-resolves through the broker on every request, so a rotated credential is always current:

import { connectHeaders } from '@stackbone/sdk/connect';

// headers: () => Promise<{ 'X-Api-Key': '<minted token>' }>
const headers = connectHeaders('example');

Scope. connect() is app-scoped and non-interactive: it resolves the agent's own service-account credential, which the operator finishes setting up (the OAuth dance, the key paste) out of band in Studio. Per-user interactive OAuth is a future phase.

When the connector isn't ready

If the operator has not finished installing the connector's credentials, connect() does not return an empty token. It throws so the runtime can surface "this needs authorization". Two error classes, re-exported from @stackbone/sdk/connect, carry that signal:

Class Meaning
ConnectionAuthorizationRequiredError The credential must be authorized out of band — the operator (or, in future, the end user) still has to finish the dance in Studio.
ConnectionAuthorizationFailedError A terminal credential failure — the connector was never installed (app_not_installed), or the broker rejected the token request.

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:

import { connect, ConnectionAuthorizationRequiredError } from '@stackbone/sdk/connect';

try {
  // ...a tool call that drives connect()'s getToken
} catch (err) {
  if (err instanceof Error && err.name === ConnectionAuthorizationRequiredError.name) {
    // Tell the operator to finish installing this connector in Studio.
  }
}

Errors

The direct-call path — stackbone.connection(id) and callConnector()throws a ConnectorCallError on failure. There is no Result envelope here. The error carries a machine-readable .code from the broker; match on the code string (again, never instanceof):

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

try {
  await callConnector('slack', 'chat.postMessage', { channel, text });
} catch (err) {
  const code = (err as { code?: string }).code;
  if (code === 'app_not_installed') {
    // The operator hasn't connected Slack yet.
  }
}

Common codes:

.code When
invalid_args The args failed the operation's input schema.
app_not_installed The connector's credentials were never installed in Studio.
credential_error The broker could not resolve a usable credential.
no_token No token is available for the requested principal.
timeout The provider call timed out.
execute_failed The broker was unreachable or the provider call failed.
invalid_output The broker returned a malformed response.

What the runtime provides

Connect relies on a behavioural contract the runtime guarantees, the same across cloud, self-host, and local dev:

  • The broker base URL is injected as STACKBONE_API_URL; the install identity as STACKBONE_INSTALLATION_ID. Both are set for you.
  • Every broker call is HMAC-signed automatically with the workspace's shared secret — you never construct a signature.
  • The broker holds the real credential and mints only a short-lived scoped token for the single call. The provider key never reaches your container.
  • Run a connector call outside stackbone dev (or a deployed runtime) and it throws an actionable error telling you to run the agent through the runtime — the broker URL and install id are not set otherwise.

Receiving connector events

A connector can also fire inbound — a Slack message arrives, an email lands. In the new runtime an inbound event starts a durable workflow run (or an agent session); it is not a synchronous handler. While developing, start that workflow directly by name to exercise the same path, no real account required:

# Start the workflow the inbound event would trigger, with your own input
stackbone workflows start onboarding --input '{ "email": "dev@example.com" }'

See the CLI command reference for the full workflows options.

Legacy connector proxy

The classic runtime exposed connectors through client.legacyConnections (list() / invoke(connector, action, args)) — a control-plane proxy that returned the Result envelope with connections_* error codes and was gated by the contract handshake. The bare top-level connection(...) export on @stackbone/sdk/workflow and @stackbone/sdk/connect is likewise deprecated in favour of the namespaced stackbone.connection(id). Prefer Stackbone Connect for all new agents; both legacy surfaces still work but are not the path forward.

Where to go next

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

© 2026 · STACKBONEBUILT WITH ❤️ FROM CANADA AND SPAIN