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 outputThere 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:
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 forconnect()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:
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 asSTACKBONE_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
- Agents and sessions — the ambient
stackboneclient and how a deep agent is authored. - Durable workflows —
'use workflow'/'use step', where most connector calls live. stackbone workflows start— start a durable workflow run locally by name.- LangChain and the Workflow SDK — the upstream frameworks Connect builds on.