StackOne AI provides a unified interface for accessing various SaaS tools through AI-friendly APIs.
Upgrading from 2.x? 3.0 has breaking changes. See the migration guide.
- Search and execute: find an action in natural language and run it, so a catalog of hundreds of tools never has to fit in a model's context
- Account discovery: an API key is enough — linked accounts are found for you
- MCP-backed: tools are fetched at runtime, and the schema a model is shown is the schema the server served
- Filtering by account, provider and glob action pattern
- Integrations: OpenAI functions, LangChain, LangGraph, Pydantic AI
- Python 3.11+
uv add stackone-aiFramework adapters are extras, imported lazily: uv add 'stackone-ai[langchain]'
(LangGraph uses this one too) or uv add 'stackone-ai[pydantic-ai]'.
Set STACKONE_API_KEY and go. The SDK finds your linked accounts itself — you
only pass an account id when you want to narrow things down.
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet()
# 1. Find an action. Each hit carries the JSON Schema for its own arguments.
hits = toolset.search("list recent comments", top_k=3)
# [{"action_id": "linear_list_comments",
# "description": "Returns a page of Linear comments as a connection object ...",
# "similarity_score": 0.86,
# "example_request": {"action_id": "linear_list_comments"},
# "input_schema": {"type": "object", "properties": {"body": {...}}},
# "session_id": "3f9c...",
# "available_accounts": [{"id": "acc-123", "name": "Acme Linear"}]}, ...]
hit = hits[0]
# 2. Run it. Build the arguments from input_schema.
result = toolset.execute(
hit["action_id"],
{"body": {"variables": {"first": 25}}},
account_ids=[hit["available_accounts"][0]["id"]],
session_id=hit.get("session_id"),
)
result["result"]["data"] # as the server wrote it: {"isError": false, "result": ...}; a failure raises
# 3. Optionally, say how it went. The same session_id links it to the search.
toolset.submit_feedback("positive", [hit["action_id"]], session_id=hit.get("session_id"))search() makes one call to StackOne's stackone_search_actions tool, which searches
every linked connector and returns actions ranked by similarity_score, so a catalog
of hundreds of tools never has to fit in a model's context. This is the recommended
way to use the SDK. top_k (1–50, default 10) caps the whole result.
Each hit names the accounts that can run it in available_accounts, so an action
linked on two accounts is one hit. execute() makes one call to
stackone_execute_action. When the action's connector is linked on more than one
account in scope, the server refuses to pick one and execute() raises
StackOneAPIError: pass the account to use as account_ids=[...], one of the hit's
available_accounts.
To hand a model the same two tools, build the toolset with
tool_mode="search_execute": fetch_tools() then returns stackone_search_actions
and stackone_execute_action, spanning every account in scope in one request, up to
50 accounts; more raise ToolsetConfigError, so pass account_ids to narrow the scope.
Several accounts also get stackone_list_accounts. For one account the server serves
that account's own search and execute pair, which the SDK presents under the same two
names: their descriptions, schemas and results read in those names, hits gain
available_accounts and provider, and a stackone_account_id or provider argument
naming that account or its connector is dropped, while any other raises
ToolArgumentsError before a request is made.
Each hit carries the session_id of the search that found it, when the server
issued one, so read it with hit.get("session_id"). Passing it to
execute() and submit_feedback() links the calls server-side; leaving it out
is fine. submit_feedback() makes exactly one call, on the account with the lowest id
among its account_ids when given, otherwise among those the toolset is configured
with or discovers, and sends action_run_id only when given. It raises
ToolsetLoadError when feedback is not enabled for your project. When it is, fetch_tools() also returns a single
stackone_submit_feedback tool — one, however many accounts are linked — that a
model can call itself.
Every block below runs as written against any linked account — the *_list_*
filter discovers whatever your key can reach rather than assuming a provider.
Each has a matching runnable script in examples/.
OpenAI
Needs no stackone-ai extra — just uv add openai.
from openai import OpenAI
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet()
tools = toolset.fetch_tools(actions=["*_list_*"])
openai_tools = tools.to_openai()[:20] # keep the catalog inside the context window
client = OpenAI()
messages = [{"role": "user", "content": "Use a tool to list a few records, then summarise them."}]
response = client.chat.completions.create(
model="gpt-5.4", messages=messages, tools=openai_tools, tool_choice="auto"
)
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True)) # the assistant turn comes first
messages.extend(tools.execute_openai_tool_calls(message.tool_calls))to_openai() emits Chat Completions function tools, and
execute_openai_tool_calls() runs the calls the model makes and returns the tool
messages to send back. A failed call becomes an error message the model can read and
retry from, rather than an exception. Chat Completions accepts at
most 128 tools, so filter before binding. See
examples/openai_integration.py for the full
round trip, including feeding results back for a final answer.
LangChain
uv add 'stackone-ai[langchain]' langchain-openaifrom langchain_openai import ChatOpenAI
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet()
tools = toolset.fetch_tools(actions=["*_list_*"])
model = ChatOpenAI(model="gpt-5.4").bind_tools(tools.to_langchain())
response = model.invoke("Use a tool to list a few records.")
for call in response.tool_calls:
tool = tools.get_tool(call["name"])
print(tool.execute(call["args"]))Pydantic AI
uv add 'stackone-ai[pydantic-ai]'from pydantic_ai import Agent
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet()
tools = toolset.fetch_tools(actions=["*_list_*"]).to_pydantic_ai()
agent = Agent("openai:gpt-5.4", tools=tools)
print(agent.run_sync("Use a tool to list a few records, then summarise them.").output)toolset.pydantic_ai() is the same thing for the unfiltered catalog, parallel to
.openai() and .langchain().
LangGraph
LangGraph consumes LangChain tools, so it goes through the same adapter.
uv add 'stackone-ai[langchain]' langchain langchain-openaifrom langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet()
tools = toolset.fetch_tools(actions=["*_list_*"]).to_langchain()
agent = create_agent(ChatOpenAI(model="gpt-5.4"), tools)
result = agent.invoke({"messages": [("user", "Use a tool to list a few records.")]})
print(result["messages"][-1].content)fetch_tools() takes three filters. They combine with AND, and all of them are
applied locally to one cached listing — changing a filter never refetches. Call
clear_catalog_cache() after linking or unlinking accounts.
toolset = StackOneToolSet()
toolset.fetch_tools() # every tool, every active shared account
toolset.fetch_tools(providers=["linear"]) # one connector
toolset.fetch_tools(actions=["linear_list_*"]) # one connector's list actions
toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool
toolset.fetch_tools(providers=["linear"],
actions=["*_get_*"]) # both filters, AND-ed
toolset.fetch_tools(account_ids=["acc-123", "acc-456"])account_ids— restrict to these accounts. Omit it and the SDK discovers your active shared accounts (non-shared ones too withinclude_non_shared=True). An empty list means "no filter", not "no accounts". A single failing account is logged and skipped, not fatal — unless it is rate limited (see Rate limits) — and left out of the cached catalog for 30 seconds, after which the next call lists it again. If every account fails with the same HTTP status (a revoked key's 401, say), thatStackOneAPIErroris raised; otherwise aToolsetLoadErrorwhosefailuresholds each account's error.execute()does not route around a failed account that could serve the action — one whose provider is the action's connector, or unknown even after oneGET /accountsto learn it: it lists it again straight away rather than after 30 seconds (unless a lookup failed to name it in the last 30 seconds, when it neither looks it up nor lists it again), and if it still fails it raisesToolsetLoadErrorrather than run the action on another account. Pass the account id to use. 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.providers— matched case-insensitively as a full prefix, soproviders=["linear"]and["LINEAR"]are the same, and a connector whose name contains an underscore must be spelled in full (["browser_linkedin"], not["browser"]). No globs here — useactionsfor that.actions— glob patterns, matched case-sensitively against the whole tool name. Exact (["linear_get_issue"]), prefix (["linear_*"]), infix (["*_list_*"]), and character classes (["linear_[lg]*"]) all work. Multiple patterns are OR'd together.
# Chaining: set_accounts() scopes every later call on this toolset.
toolset.set_accounts(["acc-123"])
tools = toolset.fetch_tools(providers=["linear"])A non-shared account (shared: false in GET /accounts) belongs to a single end user.
Discovery skips them, so a toolset built from an API key alone never puts one end user's
accounts in front of everyone, and logs once per discovery which it skipped. If every
active account on the key is non-shared, discovery raises ToolsetConfigError instead.
Pass their ids to use them, or opt in:
toolset = StackOneToolSet(include_non_shared=True) # discovery includes non-shared accountsThe API requires every MCP request for a non-shared account to carry that account's
end-user id in x-end-user-id. Whenever the SDK calls GET /accounts, during discovery
or in fetch_accounts(), it records the origin_username of each account with
shared: false, skipped or not, and sends it as x-end-user-id on every request for that
account, including from tools it built earlier. Each successful GET /accounts replaces
the record unless a later-started one has already been recorded; clear_catalog_cache()
keeps it.
With explicit account ids the SDK does not call GET /accounts up front. When the API
refuses a request for an account with no recorded end user because it needs one, the SDK
calls GET /accounts once (joining one already in flight), and if that names the account's
end user, sends the request again with it; otherwise the API's 400 is raised, with the
lookup's error as its __cause__ if the lookup failed (a rate-limited lookup raises its
429 StackOneAPIError instead). Calling
toolset.fetch_accounts() first saves that round trip:
toolset = StackOneToolSet(account_id="acc-123")
toolset.fetch_accounts() # records acc-123's end user if it is not shared
tools = toolset.fetch_tools()Or pass the end-user id yourself; a value recorded from GET /accounts replaces it:
toolset = StackOneToolSet(account_id="acc-123", headers={"x-end-user-id": "carol"})StackOneToolSet(headers={...}) sends extra headers on every request the toolset makes,
GET /accounts and every MCP request. Authorization, x-account-id and User-Agent
are always the SDK's own: if you pass them they are ignored, with a warning.
The SDK exposes the same actions through two surfaces. Both execute over MCP
tools/call, on the endpoint and with the account that listed the tool, and send
your arguments as given, except header arguments: each is forwarded only if the tool's
schema declares it, and Authorization, x-account-id, User-Agent and x-end-user-id
never are (see
the migration guide). They take different
argument shapes, because each mirrors the schema the server served for it. Both return the server's result exactly
as it wrote it: {"isError": false, "result": ..., "defenderMetadata"?, "policyMetadata"?}.
search() + toolset.execute() |
fetch_tools() + tool.execute() |
|
|---|---|---|
| Schema to read | input_schema on each hit |
tool.parameters.properties |
| Argument shape | nested — {"body": {"variables": {...}}} |
the keys the tool's served schema names |
| Best for | agents that discover actions at run time | binding a fixed, filtered set of tools to a model |
# The search/execute surface always takes the nested form:
toolset.execute("linear_list_comments", {"body": {"variables": {"first": 25}}})
# A fetch_tools() tool takes whatever its own schema declares:
tool = toolset.fetch_tools(actions=["linear_list_comments"]).get_tool("linear_list_comments")
print(tool.parameters.properties)Keys toolset.execute() does not recognise are not an error — they are dropped,
and you get the server's defaults.
Results keep the exact value of an integer above 2^53, unlike the Node SDK, whose JSON parser has already rounded it by the time the SDK sees it.
A file action's result is a download link, {"download_url", "expires_at", "file": {"name", "content_type", "content_length"}}, rather than the file itself; the SDK does
not follow it, and file.name is chosen by the provider, so reduce it to a safe
basename before writing to disk. When no link can be issued the call raises StackOneAPIError with
status_code 501.
Every request the SDK makes (GET /accounts and each MCP request, tools/call
included) retries an HTTP 429 up to three times. It waits for the response's
Retry-After, in seconds or as an HTTP date and capped at 30 seconds, or without one
for 1s, 2s and 4s, each scaled by a random factor between 0.5 and 1. Each retry logs a
warning. No other status is retried. A wait that would not end before the request's
timeout is not started: an MCP request's deadline is its whole exchange, and
GET /accounts's is measured from its first attempt.
A 429 that is still there after the fourth attempt, or that there is no time left to
wait out, raises StackOneAPIError with
status_code 429 and the server's body. That ends the whole call: when fetch_tools()
or search() spans several accounts, one rate-limited account raises rather than being
skipped, so you never get a partial catalog that looks complete. Other per-account
failures are still logged and skipped.
from stackone_ai import StackOneToolSet
toolset = StackOneToolSet(base_url="https://api.example-dev.com")Falls back to the STACKONE_BASE_URL environment variable, then to
https://api.stackone.com, if base_url is not given.
For more examples, check out the examples/ directory:
- OpenAI Integration — OpenAI function calling
- Search and Execute — the recommended flow
- LangChain Integration — LangChain tools
- LangGraph Integration — LangGraph agent
- Pydantic AI Integration — Pydantic AI agent
- Auth Management — API key and account ID patterns
Development uses uv for Python and make as the task runner.
# Core only
make install
# Everything: adapters, examples and dev tooling
make install extras=1make # list all targets
make format # fix lint, format, and type check
make test # run all testsLinting, type checking and tests run in CI on every push; there are no git hooks.
Tests that exercise the MCP mock server need its Node dependencies
(pnpm provides tsx, which runs the server):
pnpm installWithout these, those tests fail rather than skip.
Apache 2.0 License