Skip to content

About

integrations for ai agents

Resources

Stars

4 stars

Watchers

3 watching

Forks

Repository files navigation

StackOne AI SDK

PyPI version GitHub release (latest by date) Coverage DeepWiki

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.

Features

  • 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

Requirements

  • Python 3.11+

Installation

uv add stackone-ai

Framework adapters are extras, imported lazily: uv add 'stackone-ai[langchain]' (LangGraph uses this one too) or uv add 'stackone-ai[pydantic-ai]'.

Quick Start

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.

Integration Examples

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-openai
from 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-openai
from 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)

Advanced Filtering

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 with include_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), that StackOneAPIError is raised; otherwise a ToolsetLoadError whose failures holds 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 one GET /accounts to 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 raises ToolsetLoadError rather 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 one GET /accounts; like any account, it is listed again once its 30 seconds are up.
  • providers — matched case-insensitively as a full prefix, so providers=["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 — use actions for 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"])

Non-shared accounts

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 accounts

The 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"})

Extra headers

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.

Two ways to call a tool

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.

Rate limits

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.

Custom base URL

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.

Examples

For more examples, check out the examples/ directory:

Development

Setup

Development uses uv for Python and make as the task runner.

# Core only
make install

# Everything: adapters, examples and dev tooling
make install extras=1

Common Commands

make          # list all targets
make format   # fix lint, format, and type check
make test     # run all tests

Linting, type checking and tests run in CI on every push; there are no git hooks.

Integration Tests

Tests that exercise the MCP mock server need its Node dependencies (pnpm provides tsx, which runs the server):

pnpm install

Without these, those tests fail rather than skip.

License

Apache 2.0 License

About

integrations for ai agents

Resources

Stars

4 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages