3.0 is a thin client over StackOne's MCP endpoint. Every tool call goes over MCP
tools/call, and the only other request the SDK makes is GET /accounts, to find
your linked accounts. Search runs on the server. Anything MCP does not support has
been removed rather than kept on a second transport.
Work through the sections that apply to you. Each one gives the 2.x code and its 3.0 replacement.
- Installation
- Imports
- Search and execute
- Results
- Tool arguments and headers
- Base URL
- Accounts
- Feedback
- Schemas given to a model
- Hand-built tools and
ExecuteConfig - LangGraph helpers
- Python 3.11 or later is required. 3.10 is no longer supported.
mcpis a core dependency. Thestackone-ai[mcp]extra no longer exists.langchain-coreis now optional.to_langchain()andtoolset.langchain()need thelangchainextra and raise anImportErrorthat says so without it.bm25sandnumpyare no longer dependencies, because search now runs on the server.
# 2.x
uv add 'stackone-ai[mcp]'
# 3.0: add the extras for the frameworks you use
uv add stackone-ai
uv add 'stackone-ai[langchain]' # LangChain and LangGraph
uv add 'stackone-ai[pydantic-ai]' # Pydantic AIstackone_ai.models is split into stackone_ai.tools and stackone_ai.types. Anything
public can also be imported from the package root.
# 2.x
from stackone_ai.models import ExecuteConfig, StackOneTool, ToolParameters, Tools
from stackone_ai.toolset import ToolsetConfigError
# 3.0
from stackone_ai import ExecuteConfig, StackOneTool, ToolParameters, Tools, ToolsetConfigErrorToolsetError, ToolsetConfigError and ToolsetLoadError now subclass StackOneError,
so except StackOneError catches everything the SDK raises. Existing
except ToolsetError clauses behave as before.
Unusable tool arguments raise the new ToolArgumentsError, before any request: a JSON
string that does not parse or does not parse to an object, an argument that cannot be
encoded as JSON, or tool.call() given both positional and keyword arguments, or more
than one positional argument. Raised by tool.execute(), tool.call() and
toolset.execute() alike. It subclasses both StackOneError and ValueError, which is
what 2.x raised, so existing except ValueError clauses still catch it.
These names have been removed and have no direct replacement:
| Removed | Use instead |
|---|---|
SearchConfig, SearchMode, SearchTool, SemanticSearchClient, SemanticSearchResult, SemanticSearchResponse, SemanticSearchError |
toolset.search(). See Search and execute |
stackone_ai.feedback, create_feedback_tool |
toolset.submit_feedback(). See Feedback |
stackone_ai.integrations |
LangGraph's own APIs. See LangGraph helpers |
ToolDefinition |
StackOneTool fields: name, description, parameters |
ParameterLocation, validate_method |
Nothing. ExecuteConfig no longer has a method or parameter locations |
StackOneTool.connector, Tools.get_connectors() |
fetch_tools(providers=[...]) to filter by provider. A provider name can contain _ (browser_linkedin), so do not split the tool name |
Some names were public on main for a while before 3.0 but never in a release:
StackOneRpcTool, MCP_PARAM_STYLE, is_json_content_type and
filename_from_content_disposition. They are gone as well.
Client-side search is gone: the BM25/TF-IDF index, the semantic search client, the
search= constructor argument, and the tool_search/tool_execute meta tools. The
server's stackone_search_actions tool now does the search, and toolset.execute()
runs an action by id through the server's stackone_execute_action tool. Each is one
call spanning every account in scope.
# 2.x
toolset = StackOneToolSet(search={"method": "auto"})
tools = toolset.search_tools("list employees", top_k=5)
result = tools[0].execute({"query_page_size": 25})
names = toolset.search_action_names("time off requests", top_k=5)
# 3.0
toolset = StackOneToolSet()
hits = toolset.search("list employees", top_k=5) # a list of action dicts, best first
hit = hits[0]
result = toolset.execute(
hit["action_id"],
{"query": {"page_size": 25}}, # the nested form in hit["input_schema"]
account_ids=[hit["available_accounts"][0]["id"]],
session_id=hit.get("session_id"),
)search() returns the server's hits: dicts carrying action_id, provider and
available_accounts (each {"id", "name"}), plus description, similarity_score,
input_schema and example_request when the server sends them, and the search's
session_id. top_k caps the whole result, as in 2.x; the same action linked on two
accounts is one hit naming both. When an action's connector is linked on more than one
account in scope, the server refuses to pick one and execute() raises
StackOneAPIError unless account_ids names one, such as
[hit["available_accounts"][0]["id"]].
toolset.execute() has a new signature. It used to take a meta tool name, such as
"tool_execute", with arguments as a JSON string or a dict. It now takes
(action_id, arguments=None, *, account_ids=None, session_id=None), and arguments
must be a dict. It raises instead of returning {"error": ...}: ToolsetConfigError
before any request when action_id, arguments or session_id is malformed,
ToolArgumentsError when the arguments cannot be encoded as JSON, ToolsetLoadError when
the server did not serve stackone_execute_action (the project's policy disables
search_execute), and StackOneAPIError when the action fails, including when the server
rejects the arguments or the action id.
To give a model the search and execute tools, set the tool mode on the toolset.
openai(), langchain() and pydantic_ai() no longer take mode=.
# 2.x
toolset = StackOneToolSet(search={"method": "auto"})
openai_tools = toolset.openai(mode="search_and_execute")
# ...for each tool call the model makes:
toolset.execute(call.function.name, call.function.arguments)
# 3.0: stackone_search_actions and stackone_execute_action, served by the server and
# spanning every account in scope (at most 50), with stackone_list_accounts for several
toolset = StackOneToolSet(tool_mode="search_execute")
tools = toolset.fetch_tools()
openai_tools = tools.to_openai()
# ...then run the model's tool calls and get the messages to send back:
messages.extend(tools.execute_openai_tool_calls(message.tool_calls))For a single account the server serves that account's own pair,
<provider>_<account>_search_actions and _execute_action; the SDK presents them as
stackone_search_actions and stackone_execute_action, so code and prompts use the same
names for one account or many. A scope of more than 50 accounts raises
ToolsetConfigError before any request.
get_search_tool() and semantic_client are removed. Call toolset.search() instead.
Every tool returns the server's result as the server wrote it. For an action,
that is {"isError": False, "result": ..., "defenderMetadata"?, "policyMetadata"?}.
Search results are bare JSON. This applies to tool.execute(), tool.call() and
toolset.execute().
tool = toolset.fetch_tools(account_ids=[account_id]).get_tool("hibob_list_employees")
# 2.x
employees = tool.execute({})["data"]
# 3.0
employees = tool.execute({})["result"]["data"]A result with isError set raises StackOneAPIError, which carries the status from the
payload in status_code and the body in response_body.
Integers above 2^53 keep their exact value. This SDK parses the server's JSON itself, so it is not limited to the integer precision of IEEE 754 doubles; the Node SDK's result can differ for a field that size, because its JSON parser has already rounded it by the time the SDK sees it.
File actions return a download link, not bytes. The SDK does not follow the link.
When no link can be issued, the call raises StackOneAPIError with status_code 501.
# 2.x
result = download.execute({"id": "file-id"})
with open(result["file_name"] or "download.bin", "wb") as f:
f.write(result["content"])
# 3.0
from pathlib import Path
import httpx
link = download.execute({"id": "file-id"})["result"]
# {"download_url": ..., "expires_at": ..., "file": {"name", "content_type", "content_length"}}
name = Path(link["file"]["name"] or "download.bin").name # chosen by the provider: keep only the basename
Path(name).write_bytes(httpx.get(link["download_url"]).content)Arguments are sent exactly as given, as tools/call arguments. The SDK no longer
splits flat path_/query_/body_ keys into a request envelope, because the server
maps them itself.
fetch_tools() tools have the server's own argument shape. In 2.x the SDK asked
the server for the flat, prefixed style (?param-style=flat_prefixed). That request is
gone, so the argument names are whatever the server serves. Read them from
tool.parameters.properties rather than hard-coding them:
# 2.x
tool.execute({"body_variables": {"first": 25}})
# 3.0: use the keys the schema names, for example
print(tool.parameters.properties)
tool.execute({"body": {"variables": {"first": 25}}})Header arguments are allowlisted. A header argument is an entry of a headers
object argument, or a top-level headers_<name> argument. Each one is forwarded only
if the tool's schema declares it in the same form: under headers.properties, or as a
headers_<name> property. An open headers object, "type": "object" with no
properties and additionalProperties not false, declares every name; without
"type": "object", or with additionalProperties: false and no properties, it
declares none. Authorization, x-account-id, User-Agent and x-end-user-id are
never forwarded, even when declared, because the SDK sets them itself. A dropped header argument is
logged as a warning, except a null value, which is omitted silently. Every other
argument is sent unchanged.
A top-level headers argument that isn't a plain object is dropped with a warning,
unless the schema declares headers itself as a non-object field, in which case it's
an ordinary argument that happens to be named headers: it skips header filtering and is
sent as given, subject to the same JSON-value check as every other argument. A
headers_<name> argument is dropped with a warning when its value is a list or dict.
stackone_execute_action serves an open headers object, so toolset.execute() passes your
own headers on to the action, with the exception of those four:
toolset.execute("linear_list_comments", {"headers": {"x-request-id": "abc"}})STACKONE_BASE_URL is now read. base_url still takes precedence when given;
otherwise the SDK now falls back to STACKONE_BASE_URL before the default,
https://api.stackone.com. An empty base_url argument or an empty
STACKONE_BASE_URL is treated as not set, not as a literal empty host.
With no account id, the SDK discovers your accounts. In 2.x, calling
fetch_tools() with no account listed tools without an x-account-id. In 3.0 it asks
GET /accounts and lists the catalog of every active shared account (non-shared ones
too with include_non_shared=True). If you have many
accounts, pass account_id=, account_ids= or call set_accounts() so the SDK does
not fetch every catalog.
Discovery skips non-shared accounts. An account with shared: false belongs to a
single end user, so discovery leaves it out and logs a warning naming it; when every
active account is non-shared it raises ToolsetConfigError instead. Pass its id, or
construct the toolset with include_non_shared=True, to use it.
A non-shared account's end user is sent for you. The API requires every MCP request
for a non-shared account to carry the account's end-user id in x-end-user-id. Whenever
the SDK calls GET /accounts (discovery, or fetch_accounts()), it records the
origin_username of each account with shared: false and sends it as x-end-user-id on
every request for that account. With explicit account ids it makes no GET /accounts up
front: the first request for a non-shared account is refused, and the SDK then calls
GET /accounts once and retries it with the end user it records. Call
toolset.fetch_accounts() once first to skip that round trip.
STACKONE_ACCOUNT_ID is not read. Pass the account id as account_id= or
execute={"account_ids": [...]}. A toolset constructed with neither while
STACKONE_ACCOUNT_ID is set to a non-empty value logs a warning that it is ignored.
An empty account id raises. StackOneToolSet(account_id="") raises
ToolsetConfigError, where it used to be treated as no account at all. An account id
read from an environment variable that is set but empty now raises too, rather than
quietly reaching every active shared account:
# Raises when STACKONE_ACCOUNT_ID is set to ""
toolset = StackOneToolSet(account_id=os.getenv("STACKONE_ACCOUNT_ID"))
# Falls back to discovery only when the variable is unset or empty, deliberately
toolset = StackOneToolSet(account_id=os.getenv("STACKONE_ACCOUNT_ID") or None)An empty or non-string entry in account_ids, set_accounts() or
execute={"account_ids": [...]} raises ToolsetConfigError as well, and so does
passing a tuple (or any other non-list) there: pass a list, list(ids), instead. A falsy entry
(None, 0, "") used to produce an unscoped request with no x-account-id; a
truthy non-string entry used to be sent as the header's value.
get_tool() returns the first of any duplicates. When two accounts serve the same
tool name, Tools.get_tool() now returns the first one listed, where 2.x returned the
last. Listings are merged in sorted account order, and a warning names the clashing
tools. execute_openai_tool_calls(), to_openai(), to_langchain() and
to_pydantic_ai() use the same one: each builds one tool per name. Pass account_ids
to choose the account yourself.
A failing account is skipped, and left out for 30 seconds. In a multi-account scope, an
account whose listing fails with a non-429 error is skipped with a warning and left out of
the cached catalog for 30 seconds, unless every account fails: then the accounts' shared
StackOneAPIError is raised when they all failed with one status, and otherwise a
ToolsetLoadError with each account's error in failures. A 429 that outlasts its retries
is never skipped — it aborts the whole call, even when other accounts are healthy, since
it is the key's rate limit rather than that account's failure. A single-account scope
raises its failure rather than skipping or caching it. execute() does not wait the 30
seconds for an account that could serve the action, one whose provider is the action's
connector or unknown after one GET /accounts to learn it: it lists it again straight
away, and refuses to run the action on another account while it still fails.
The client-side tool_feedback tool, create_feedback_tool() and the implicit
feedback_* execution options have been removed. execute() and call() no longer
take options=.
# 2.x
feedback_tool = toolset.fetch_tools(actions=["tool_*"]).get_tool("tool_feedback")
feedback_tool.call(feedback="Worked well", account_id="acc_123", tool_names=["workday_list_workers"])
tool.execute({"id": "1"}, options={"feedback_session_id": "chat-42"})
# 3.0
hit = toolset.search("list workers")[0]
toolset.execute(hit["action_id"], session_id=hit.get("session_id"))
toolset.submit_feedback(
"positive",
[hit["action_id"]],
feedback="Worked well",
session_id=hit.get("session_id"),
)When feedback is enabled for your project, the server also serves a
stackone_submit_feedback tool. fetch_tools(), openai(), langchain() and
pydantic_ai() include it, once, however many accounts are linked. If you don't want
a model to call it, filter it out:
from stackone_ai import Tools
tools = Tools([t for t in toolset.fetch_tools() if t.name != "stackone_submit_feedback"])submit_feedback() raises ToolsetLoadError when feedback is not enabled. It calls the
tool once, on the lowest account id in scope, and sends action_run_id when given.
to_openai_function() passes the served schema through. 2.x kept only type,
description, enum and a shallow copy of items/properties. Now format,
pattern, default, bounds, oneOf/anyOf and nested required reach the model as
the server served them. to_langchain() and to_pydantic_ai_tool() hand over the
same schema.
required is the served list. 2.x derived required from each property's
internal nullable marker, which made every property of a hand-built tool with no
markers required. 3.0 emits the schema's own required list, in the order the server
sent it, and leaves it out when that list is missing or empty. Give a hand-built tool
its required list directly:
# 2.x: required, because "nullable" is absent
ToolParameters(type="object", properties={"id": {"type": "string"}})
# 3.0
ToolParameters(type="object", properties={"id": {"type": "string"}}, required=["id"])StackOneTool no longer makes HTTP requests. Its execute() raises StackOneError
on the base class, so a hand-built tool must override it. Tools from fetch_tools() are
StackOneMcpTool instances, which execute over MCP. _api_key is now optional on the
base class.
# 2.x
tool = StackOneTool(
description="Get an employee",
parameters=ToolParameters(type="object", properties={"id": {"type": "string"}}),
_execute_config=ExecuteConfig(
name="get_employee", method="GET", url="https://api.example.com/employees/{id}",
parameter_locations={"id": ParameterLocation.PATH},
),
_api_key="...",
)
# 3.0
class GetEmployee(StackOneTool):
def __init__(self) -> None:
super().__init__(
description="Get an employee",
parameters=ToolParameters(type="object", properties={"id": {"type": "string"}}, required=["id"]),
_execute_config=ExecuteConfig(name="get_employee"),
)
def execute(self, arguments=None):
args = json.loads(arguments) if isinstance(arguments, str) else dict(arguments or {})
return httpx.get(f"https://api.example.com/employees/{args['id']}").json()
tool = GetEmployee()Define __init__ on the subclass, as above. StackOneTool is a pydantic model, so a
type checker gives a subclass without its own __init__ one built from the model's
fields, which rejects _execute_config even though the call works at runtime.
ExecuteConfig keeps only name, headers and timeout, and rejects any other
field. Passing method, url, body_type or parameter_locations raises a pydantic
ValidationError rather than being silently ignored.
StackOneMcpTool(headers=...) adds headers; it does not replace them. The SDK
always sets Authorization, x-account-id and User-Agent itself, after your headers,
and drops any case variant of those names you pass. An x-end-user-id you pass is sent as
given. headers is optional.
StackOneToolSet(headers=...) is new. It sends extra headers on every request the
toolset makes, GET /accounts included, with the same rule: Authorization, x-account-id
and User-Agent are ignored with a warning, and x-end-user-id is passed through unless
GET /accounts reported the account's end user.
stackone_ai.integrations has been removed. Pass the LangChain tools to LangGraph
directly:
# 2.x
from stackone_ai.integrations import to_tool_node
node = to_tool_node(tools)
# 3.0
from langgraph.prebuilt import ToolNode
node = ToolNode(tools.to_langchain())See examples/langgraph_integration.py for a complete agent.