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 devhas 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 dottedchat.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 noResultenvelope 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:
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 theauthfield: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'sauthcarries only a Bearer; anything else rides the connection's separateheadersclosure, andconnectHeadersproduces 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
bearerplacement becomesAuthorization: Bearer …, a namedheadergets the raw token with no scheme, and a credential registered in two slots at once gets both. SoconnectHeaders('linear')sends a bareAuthorization, without you naming the header. That is what Linear wants: it answers aBearerprefix 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 throwsConnectionAuthorizationFailedErrorwithreason: 'placement_unsupported'rather than sending the request unauthenticated. Call that connector throughstackbone.connection(id).call(…)instead, where the box applies the placement server-side.connect()andwithConnect()are Bearer-only and cannot honour a placement:TokenResulthas no field for one, so the runtime prependsBearerunconditionally. Wire a non-Bearer connector throughconnectHeaders()orstackbone.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
- Calling a sibling agent:
callDeepAgent(), the closest surviving pattern for reaching another part of your workspace. @stackbone/sdkoverview: the ambient handle and the subpath import map.- Concepts → Stackbone Connect: the broker model and how an operator installs credentials.
stackbone workflows start: start a durable workflow run by name in your workspace understackbone dev.- Workflow SDK: the upstream durable execution engine Stackbone workflows build on.