npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

npm version DeepWiki Coverage

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() and execute() — 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/ai

Framework 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 accountIds to 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 add
import { 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.

View full example

npm install @stackone/ai openai  # or: yarn/pnpm/bun add
import 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(),
});

View full example

npm install @stackone/ai @anthropic-ai/sdk  # or: yarn/pnpm/bun add
import 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(),
});

View full example

npm install @stackone/ai ai @ai-sdk/openai  # or: yarn/pnpm/bun add

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

View full example

npm install @stackone/ai @anthropic-ai/claude-agent-sdk zod  # or: yarn/pnpm/bun add
import { 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
}

View full example

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's example_request shows: { path, query, body }. (A fetchTools() tool takes the keys its own served schema names instead: read them from tool.parameters.properties.)
  • The connector is the longest one whose name prefixes the action id, so browser_linkedin_* actions are not routed to browser.
  • action_id is always the one you pass: an action_id inside args — for example, one a prompt-injected model put there — is ignored.
  • Each hit carries the session_id of the search that found it, when the server issued one. Pass it back as sessionId to link the calls together.
  • Each hit carries the account_id of 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() throws ToolSetConfigError rather 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 (asking GET /accounts first 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, throws ToolSetLoadError rather 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 one GET /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.

View full example

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. Carries statusCode and responseBody, and its message carries the server's own explanation. A failed MCP tools/call raises 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 a 412.
  • ToolArgumentsError — a tool's arguments are unusable: not a JSON object, or not encodable as JSON (a NaN, a bigint, a Date, a circular reference, …). Thrown by tool.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.ts

See 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.conf

Activating the Development Environment

# Automatic activation with direnv (recommended)
direnv allow

# Or manual activation
nix develop

The flake provides all necessary development dependencies including Node.js, pnpm, and other build tools.