@stackone/ai
v3.0.0
Published
Tools for agents to perform actions on your SaaS
Readme
StackOne AI SDK
A unified interface for performing actions on SaaS tools through AI-friendly APIs.
Upgrading from 2.x? 3.0 has breaking changes. See the migration guide.
StackOneToolSet
The StackOne AI SDK provides the StackOneToolSet class, a thin client over StackOne's MCP (Model Context Protocol) endpoint. It lists the tools your linked accounts serve and executes them, handing your model each tool's schema exactly as the server served it.
It offers two ways in:
search()andexecute()— find an action in natural language and run it by id. A catalog of hundreds of tools never has to fit in a model's context. This is the recommended way to start.fetchTools()— list the catalog as tools and hand them to your framework (OpenAI, Anthropic, the AI SDK, the Claude Agent SDK, …).
Installation
# Using npm
npm install @stackone/ai
# Using yarn
yarn add @stackone/ai
# Using pnpm
pnpm add @stackone/ai
# Using bun
bun add @stackone/aiFramework packages (openai, @anthropic-ai/sdk, ai, @anthropic-ai/claude-agent-sdk) are optional peer dependencies: install the ones you use.
Usage
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. No account id needed.
const toolset = new StackOneToolSet();
// Find an action, then run it by id
const [best] = await toolset.search('list recent comments', { topK: 3 });
if (!best) throw new Error('No action matched');
const result = await toolset.execute(best.action_id, { query: { limit: 5 } });
// Or list the catalog as tools for your framework
const tools = await toolset.fetchTools({ actions: ['*_list_*'] });
const listTool = tools.getTool('workday_list_workers');
const workers = await listTool?.execute();Authentication
Set the STACKONE_API_KEY environment variable:
export STACKONE_API_KEY=<your-api-key>or load from a .env file using your preferred environment variable library, or pass apiKey to the constructor. The constructor throws a ToolSetConfigError when neither is set.
An API key alone is enough. With no account configured, the toolset asks GET /accounts which accounts the key is linked to and uses the active ones. fetchAccounts() returns that list:
for (const account of await toolset.fetchAccounts()) {
console.log(account.id, account.provider, account.status);
}If your organisation has many linked accounts, discovery lists the catalog of every one of them. Pass explicit
accountIdsto avoid the round trips and the model context they cost.
A non-shared account (shared: false in GET /accounts) belongs to one end user, and the API refuses its MCP requests unless they carry that user's id in x-end-user-id. Discovery skips non-shared accounts, with a warning naming them, so a toolset built with only an API key never puts every end user's accounts in one context: pass their account ids, or includeNonShared: true, to use them. If every active account is non-shared, discovery throws ToolSetConfigError instead. Whenever the toolset calls GET /accounts — for discovery, or through fetchAccounts() — it records each non-shared account's origin_username and sends it as x-end-user-id on every MCP request for that account, replacing any value from the headers option. With explicit account ids and nothing recorded for one, the API's refusal makes the toolset call GET /accounts once and, if that names the account's end user, send the request again with it; otherwise the API's 400 is thrown, with the lookup's error as its cause if the lookup failed — unless the lookup was rate limited, when its 429 StackOneAPIError is thrown instead. Overlapping GET /accounts calls are recorded in the order they started.
Account IDs
StackOne uses account IDs to identify different integrations. You can specify the account ID at different levels (a per-call accountIds wins, then setAccounts(), then the constructor's accountIds, then accountId, then discovery). The SDK never reads an account id from the environment, so an environment variable cannot silently change which accounts a toolset reaches. To keep yours in STACKONE_ACCOUNT_ID, read it yourself:
import { StackOneToolSet } from '@stackone/ai';
// Single account, read from your own environment variable. Unset means discovery
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
const tools = await toolset.fetchTools();
// Explicit single account
const explicitToolset = new StackOneToolSet({ accountId: 'your-workday-account' });
const explicitTools = await explicitToolset.fetchTools();
// Multiple accounts - returns tools from both integrations
const multiAccountToolset = new StackOneToolSet();
const allTools = await multiAccountToolset.fetchTools({
accountIds: ['workday-account-123', 'hibob-account-456'],
});
// Filter to specific integration when using multiple accounts
const workdayOnly = await multiAccountToolset.fetchTools({
accountIds: ['workday-account-123', 'hibob-account-456'],
actions: ['workday_*'], // Only Workday tools
});
// Rebind a single tool to another account
const tool = tools.getStackOneTool('workday_list_workers');
tool.setAccountId('direct-account-id');
const currentAccountId = tool.getAccountId();Every fetchTools() call returns fresh tool instances, so rebinding one never affects another caller's tools. When two accounts serve a tool with the same name, getTool() returns the first one listed, and the SDK warns.
Integrations
The StackOneToolSet makes it super easy to use StackOne APIs as tools in your AI applications.
npm install @stackone/ai openai # or: yarn/pnpm/bun addimport { OpenAI } from 'openai';
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. Without an account id, every active shared account
// is used (non-shared ones too with includeNonShared: true)
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
const tools = await toolset.fetchTools({ actions: ['workday_*'] });
const openai = new OpenAI();
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: 'system', content: 'You are a helpful HR assistant using Workday.' },
{ role: 'user', content: 'Create a time-off request for employee id cxIQ5764hj2' },
];
const response = await openai.chat.completions.create({
model: 'gpt-5.1',
messages,
tools: tools.toOpenAI(),
});
// Run the model's tool calls and send the results back
const message = response.choices[0].message;
messages.push(message, ...(await tools.executeOpenAIToolCalls(message.tool_calls)));executeOpenAIToolCalls() never throws for a failed call: the error, with the server's explanation, becomes the tool message, so the model can correct itself.
npm install @stackone/ai openai # or: yarn/pnpm/bun addimport OpenAI from 'openai';
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. Without an account id, every active shared account
// is used (non-shared ones too with includeNonShared: true)
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
const tools = await toolset.fetchTools();
const openai = new OpenAI();
await openai.responses.create({
model: 'gpt-5.1',
instructions: 'You are a helpful HR assistant.',
input: 'What is the phone number for employee c28xIQ?',
tools: tools.toOpenAIResponses(),
});npm install @stackone/ai @anthropic-ai/sdk # or: yarn/pnpm/bun addimport Anthropic from '@anthropic-ai/sdk';
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. Without an account id, every active shared account
// is used (non-shared ones too with includeNonShared: true)
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
const tools = await toolset.fetchTools();
const anthropic = new Anthropic();
await anthropic.messages.create({
model: 'claude-haiku-4-5-20241022',
max_tokens: 1024,
system: 'You are a helpful HR assistant.',
messages: [
{
role: 'user',
content: 'What is the phone number for employee c28xIQ?',
},
],
tools: tools.toAnthropic(),
});npm install @stackone/ai ai @ai-sdk/openai # or: yarn/pnpm/bun addSupports AI SDK v5–v7 (
ai>=5.0.108 <8.0.0). AI SDK v7 requires Node.js 22+.
import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs } from 'ai';
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. Without an account id, every active shared account
// is used (non-shared ones too with includeNonShared: true)
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
const tools = await toolset.fetchTools();
await generateText({
model: openai('gpt-5.1'),
tools: await tools.toAISDK(),
stopWhen: stepCountIs(3),
});npm install @stackone/ai @anthropic-ai/claude-agent-sdk zod # or: yarn/pnpm/bun addimport { query } from '@anthropic-ai/claude-agent-sdk';
import { StackOneToolSet } from '@stackone/ai';
// Reads STACKONE_API_KEY from the environment. Without an account id, every active shared account
// is used (non-shared ones too with includeNonShared: true)
const toolset = new StackOneToolSet({ accountId: process.env.STACKONE_ACCOUNT_ID || undefined });
// Fetch tools and convert to Claude Agent SDK format
const tools = await toolset.fetchTools();
const mcpServer = await tools.toClaudeAgentSdk();
// Use with Claude Agent SDK query
const result = query({
prompt: 'Get the employee with id: abc123',
options: {
model: 'claude-sonnet-4-5-20250929',
mcpServers: { 'stackone-tools': mcpServer },
tools: [], // Disable built-in tools
maxTurns: 3,
},
});
for await (const message of result) {
// Process streaming messages
}Features
Search and execute
search() asks every linked connector for actions matching a natural-language query and ranks the results together, best first. execute() runs one by id, through the connector that serves it.
const actions = await toolset.search('create a time-off request', { topK: 5 });
for (const action of actions) {
console.log(action.similarity_score, action.action_id);
}
const [best] = actions;
if (!best) throw new Error('No action matched');
// `input_schema` describes what the action accepts; `example_request` shows the shape
const result = await toolset.execute(best.action_id, best.example_request, {
accountIds: [best.account_id],
sessionId: best.session_id,
});execute()takes the nested envelope an action'sexample_requestshows:{ path, query, body }. (AfetchTools()tool takes the keys its own served schema names instead: read them fromtool.parameters.properties.)- The connector is the longest one whose name prefixes the action id, so
browser_linkedin_*actions are not routed tobrowser. action_idis always the one you pass: anaction_idinsideargs— for example, one a prompt-injected model put there — is ignored.- Each hit carries the
session_idof the search that found it, when the server issued one. Pass it back assessionIdto link the calls together. - Each hit carries the
account_idof the account that found it, and the same action linked on two accounts is two hits. When an action's connector is linked on more than one account,execute()throwsToolSetConfigErrorrather than pick one: pass the hit's account as{ accountIds: [hit.account_id] }. Likewise, if an account in scope failed to list and its provider is the action's connector or unknown,execute()lists it again at once (askingGET /accountsfirst for any provider it does not know, and throwing its 429 if rate limited; an account that lookup failed to name is neither asked about nor listed early again for 30 seconds), and if it still fails, throwsToolSetLoadErrorrather than run the action on whichever account did list. A failed account on another provider is not listed again early, so it never costs the call a listing timeout, though learning its provider with explicit ids takes oneGET /accounts; like any account, it is listed again once its 30 seconds are up. topK(1–50, default 10) caps the whole result, after ranking across every connector. A connector that fails to search is skipped with a warning, unless every connector fails.
Feedback
When feedback is enabled for your project, the server serves a stackone_submit_feedback tool alongside the others, in every tool mode. A model can call it like any other tool, and submitFeedback() calls it for you:
await toolset.submitFeedback({
rating: 'negative', // 'positive' | 'negative' | 'neutral'
toolNames: [best.action_id],
feedback: 'Needed two calls to find the right action',
category: 'search', // optional
sessionId: best.session_id, // optional: links the feedback to that search
actionRunId: 'run_123', // optional: the action run the feedback is about
});submitFeedback() makes exactly one call, on the account with the lowest id among its accountIds when given, otherwise among those the toolset is configured with or discovers. The SDK never builds a feedback tool itself: if the server does not serve it, submitFeedback() throws a ToolSetLoadError saying feedback is not enabled for the project.
Tool modes
// One tool per action (the server default)
const toolset = new StackOneToolSet();
// Two meta tools per connector instead: `*_search_actions` and `*_execute_action`
const compact = new StackOneToolSet({ toolMode: 'search_execute' });
const metaTools = await compact.fetchTools();search_execute keeps the catalog small however many accounts are linked, which suits handing the tools to a model directly. A single call can override the mode with fetchTools({ mode }).
Filtering Tools with fetchTools()
You can filter tools by account IDs, providers, and action patterns:
// Filter by account IDs
toolset.setAccounts(['account-123', 'account-456']);
const tools = await toolset.fetchTools();
// OR
const tools = await toolset.fetchTools({
accountIds: ['account-123', 'account-456'],
});
// Filter by providers (a full, case-insensitive prefix of the tool name)
const tools = await toolset.fetchTools({ providers: ['hibob', 'workday'] });
// Filter by actions with exact match
const tools = await toolset.fetchTools({
actions: ['hibob_list_employees', 'hibob_create_employees'],
});
// Filter by actions with glob patterns (`*`, `?`, `[seq]`, `[!seq]`)
const tools = await toolset.fetchTools({ actions: ['*_list_employees'] });
// Combine multiple filters
const tools = await toolset.fetchTools({
accountIds: ['account-123'],
providers: ['hibob'],
actions: ['*_list_*'],
});This is especially useful when you want to:
- Limit tools to specific linked accounts
- Focus on specific HR/CRM/ATS providers
- Get only certain types of operations (e.g., all "list" operations)
The catalog is cached per account scope and mode, so changing a filter never refetches it. Call clearCatalogCache() after linking or unlinking accounts; a listing already in flight is neither cached nor shared with later calls. An account that fails to list tools is skipped with a warning and left out of the cached catalog for 30 seconds, after which the next call lists it again. execute() does not wait: it lists a failed account that could serve the action again straight away. If every account fails with the same HTTP status (a revoked key's 401, say), that StackOneAPIError is thrown; otherwise a ToolSetLoadError whose cause holds each account's error.
Tool schemas
tool.toJsonSchema() is the schema the server served, verbatim — $schema, $defs, $ref, title, additionalProperties, top-level oneOf/anyOf/allOf and every nested constraint — with the served required list. It is a fresh copy each call. The framework adapters start from it and change only what the provider would otherwise reject:
| Adapter | Adjustment |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| toJsonSchema() | None: lossless. |
| toOpenAI(), toAnthropic(), toClaudeAgentSdkTool() | A top-level oneOf/anyOf/allOf is folded into the root: every branch's properties become root properties, and an allOf branch's required is merged in. OpenAI also rejects a top-level enum/not, so those are dropped. |
| toOpenAIResponses() | As above. With strict (the default) the root also gets additionalProperties: false. OpenAI's strict mode additionally requires every property to be required and every nested object closed, which the SDK does not rewrite, so pass { strict: false } for schemas with optional fields. |
| toAISDK() | As toOpenAI(), plus additionalProperties: false on the root. |
Headers
A tool call's header arguments — the entries of a headers object argument, and headers_<name> arguments — are forwarded only if the tool's served schema declares them: <name> under properties.headers.properties, or a headers_<name> property. A headers property served as an open object — type: "object", no properties, and additionalProperties not false — as on every *_execute_action, declares any name, so toolset.execute(id, { headers: { 'x-custom': '…' } }) forwards it. Anything undeclared is dropped with a warning; every other argument is sent unchanged.
A top-level headers argument that isn't a plain object is dropped with a warning, unless the served schema declares headers itself as a non-object field (for example type: "string"), in which case it's an ordinary argument that happens to be named headers and is sent as given. A headers_<name> argument is dropped with a warning when its value is an array or object — only a string, number or boolean can be a header value.
Authorization, x-account-id and User-Agent are always the SDK's own: they are dropped from header arguments even when declared, and ignored with a warning in the headers constructor option, so nothing can replace the credential or run a call against another account. x-end-user-id is dropped from header arguments the same way, so a model cannot choose the end user; in the headers option it is sent as given, unless GET /accounts reported the account's end user (see Authentication), which replaces it.
// Extra headers for every request (for example, tracing)
const toolset = new StackOneToolSet({ headers: { 'X-Request-Source': 'my-agent' } });Errors and timeouts
Everything the SDK throws is a StackOneError:
StackOneAPIError— the API refused a request. CarriesstatusCodeandresponseBody, and its message carries the server's own explanation. A failed MCPtools/callraises one too, with the status from its payload and the result's text in the message (Tool "hibob_get_employee" failed: …); a dead account surfaces as a412.ToolArgumentsError— a tool's arguments are unusable: not a JSON object, or not encodable as JSON (aNaN, abigint, aDate, a circular reference, …). Thrown bytool.execute()before any request is made.ToolSetConfigError— the toolset was configured, or called, with something it cannot use.ToolSetLoadError— the catalog, or the accounts behind it, could not be loaded.
timeout (milliseconds, default 60000) bounds every MCP listing and tools/call, and account discovery.
Rate limits
Every request the SDK makes (GET /accounts and every MCP request, tools/call included) is retried when the API answers 429 Too Many Requests: up to 3 more times, waiting for the response's Retry-After (capped at 30s), or 1s, 2s, then 4s with jitter when there is none. Each retry logs a warning. A request still rate limited after that throws StackOneAPIError with statusCode 429, and fails the whole fetchTools() or search() call: unlike a dead account, a rate-limited one is never skipped, so you never get a partial catalog. The retries' waits count towards timeout, and a wait that would outlast it is not started: the 429 is thrown at once, so a rate limit never turns into a timeout.
Custom Base URL
import { StackOneToolSet } from '@stackone/ai';
const toolset = new StackOneToolSet({ baseUrl: 'https://api.example-dev.com' });Testing with dryRun
You can use the dryRun option to see the tools/call a tool would send, without sending it:
import { StackOneToolSet } from '@stackone/ai';
const toolset = new StackOneToolSet();
const tools = await toolset.fetchTools();
const employeeTool = tools.getTool('workday_list_workers');
const dryRunResult = await employeeTool?.execute({ query: { limit: 5 } }, { dryRun: true });
console.log(dryRunResult);
// {
// url: "https://api.stackone.com/mcp",
// method: "tools/call",
// name: "workday_list_workers",
// arguments: { query: { limit: 5 } }
// }File Downloads
Every tool executes over MCP tools/call, and a tool returns the server's result exactly as the server wrote it: { isError: false, result, defenderMetadata?, policyMetadata? }. A tool result cannot carry a file, so a file action (for example googledrive_unified_download_file or any *_download_file) returns a single-use link as its result:
const response = await tools
.getTool('googledrive_unified_download_file')
?.execute({ path: { id: 'file-id' } });
// {
// isError: false,
// result: {
// download_url: "https://api.stackone.com/actions/download/v1.…",
// expires_at: "2026-09-29T12:05:00.000Z",
// file: { name: "report.pdf", content_type: "application/pdf", content_length: 48213 }
// }
// }GET the download_url for the bytes: it needs no credentials, works once, and expires after five minutes. The filename in file.name and in the download's Content-Disposition is chosen by whoever uploaded the file, so reduce it to a safe basename before writing it to disk. Where no link can be issued, the call raises a StackOneAPIError with status 501.
Examples
Running Examples
# 1. Set up credentials
cp .env.example .env
# Edit .env with your API keys
# 2. Install dependencies
pnpm install
# 3. Run any example
pnpm run:example examples/openai-integration.tsSee the examples/ directory for the full list.
Development Environment
Using Nix Flake
This project includes a Nix flake for reproducible development environments. All development tools are defined in flake.nix and provided via Nix.
Installing Nix
# Install Nix with flakes enabled (if not already installed)
curl --proto '=https' --tlsv1.2 -sSf -L https://artifacts.nixos.org/experimental-installer | \
sh -s -- install
# If flakes are not enabled, enable them with:
mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.confActivating the Development Environment
# Automatic activation with direnv (recommended)
direnv allow
# Or manual activation
nix developThe flake provides all necessary development dependencies including Node.js, pnpm, and other build tools.
