Metadata-Version: 2.5
Name: stackone-ai
Version: 3.0.0
Summary: agents performing actions on your SaaS
Author-email: StackOne <support@stackone.com>
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: httpx>=0.28.0
Requires-Dist: mcp<2.0.0,>=1.24.3
Requires-Dist: pydantic>=2.10.6
Provides-Extra: dev
Requires-Dist: hypothesis>=6.141.1; extra == 'dev'
Requires-Dist: langchain-core>=0.3.36; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.25.3; extra == 'dev'
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
Requires-Dist: pytest>=8.3.4; extra == 'dev'
Requires-Dist: ruff>=0.9.6; extra == 'dev'
Requires-Dist: ty>=0.0.3; extra == 'dev'
Provides-Extra: examples
Requires-Dist: langchain-openai>=1.1.14; extra == 'examples'
Requires-Dist: langchain>=1.0.0; extra == 'examples'
Requires-Dist: openai>=2.32.0; extra == 'examples'
Requires-Dist: pydantic-ai>=1.83.0; extra == 'examples'
Requires-Dist: python-dotenv>=1.0.1; extra == 'examples'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3.36; extra == 'langchain'
Provides-Extra: pydantic-ai
Requires-Dist: pydantic-ai-slim>=1.83.0; extra == 'pydantic-ai'
Description-Content-Type: text/markdown

# StackOne AI SDK

[![PyPI version](https://badge.fury.io/py/stackone-ai.svg)](https://badge.fury.io/py/stackone-ai)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/StackOneHQ/stackone-ai-python)](https://github.com/StackOneHQ/stackone-ai-python/releases)
[![Coverage](https://stackonehq.github.io/stackone-ai-python/badges.svg)](https://stackonehq.github.io/stackone-ai-python/html/)
[![DeepWiki](https://img.shields.io/badge/DeepWiki-StackOneHQ%2Fstackone--ai--python-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/StackOneHQ/stackone-ai-python)

<!-- DeepWiki badge generated by https://deepwiki.ryoppippi.com/ -->

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](https://github.com/StackOneHQ/stackone-ai-python/blob/main/MIGRATION.md).

## 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

```bash
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.

```python
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/](examples/).

<details>
<summary>OpenAI</summary>

Needs no `stackone-ai` extra — just `uv add openai`.

```python
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](examples/openai_integration.py) for the full
round trip, including feeding results back for a final answer.

</details>

<details>
<summary>LangChain</summary>

```bash
uv add 'stackone-ai[langchain]' langchain-openai
```

```python
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"]))
```

</details>

<details>
<summary>Pydantic AI</summary>

```bash
uv add 'stackone-ai[pydantic-ai]'
```

```python
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()`.

</details>

<details>
<summary>LangGraph</summary>

LangGraph consumes LangChain tools, so it goes through the same adapter.

```bash
uv add 'stackone-ai[langchain]' langchain langchain-openai
```

```python
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)
```

</details>

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

```python
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](#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.

```python
# 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:

```python
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:

```python
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:

```python
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](MIGRATION.md#tool-arguments-and-headers)). 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 |

```python
# 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

```python
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/](examples/) directory:

- [OpenAI Integration](examples/openai_integration.py) — OpenAI function calling
- [Search and Execute](examples/search_and_execute.py) — the recommended flow
- [LangChain Integration](examples/langchain_integration.py) — LangChain tools
- [LangGraph Integration](examples/langgraph_integration.py) — LangGraph agent
- [Pydantic AI Integration](examples/pydantic_ai_integration.py) — Pydantic AI agent
- [Auth Management](examples/auth_management.py) — API key and account ID patterns

## Development

### Setup

Development uses [uv](https://docs.astral.sh/uv/) for Python and `make` as the task runner.

```bash
# Core only
make install

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

### Common Commands

```bash
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](https://pnpm.io/) provides `tsx`, which runs the server):

```bash
pnpm install
```

Without these, those tests fail rather than skip.

## License

Apache 2.0 License
