Toolkit

Toolkits: your connections' operations, the operations that need no account, and capabilities as LLM tools.

gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True)
toolkit = noclick.Toolkit(["search", gmail])
response = anthropic.messages.create(..., tools=toolkit.definitions())
messages.append({"role": "user", "content": toolkit.handle(response)})

A call never raises for something the model can act on: a missing connection, a policy refusal or a hold for approval comes back as an is_error result. A hold's approval_required (approval_id, request_id, approve_url, operation, approver) is never shown to the model; pass on_approval= to the handle helpers to receive it, and on_connect= to receive each result that needs a connection. request_id (the request a person answers), approve_url and operation (the approval's operation object) are None unless the request is addressed to you; the approver is emailed either way. Once the person approves, the identical call goes through.

noclick.Toolkit(tools, *, user=..., client=None, simulated=False, scenario=None)

A set of tools for your LLM. Entries are connections (a Connection or its name: every operation, as {connection}__{operation} tools), single operations ("gmail.send_email_message"), capabilities ("search", "fetch", "code") or a Database ({database}__query, __execute and __schema; invite-only beta), a Bucket and operations with arguments bound (gmail.only("send_email_message").bind(to=...): the bound parameters leave the tool's schema and are filled on every call, so a toolkit sends its entries with each call). A name resolves in the user the calls act for (user= if given, else the user current at each call, as_user): a connection there, else an integration's operations that need no account (on NoClick's key), so "exa.search" needs no connection. A Connection object from another scope, another user's or your account's, stays that connection (invite-only beta).

Parameters
toolsstr | Iterable[Any]required
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
simulatedbooldefault False
scenariostr | Nonedefault None
Usage

Build one toolkit at module level and use it for every user: "gmail" is each user's own connection named gmail, and "exa.search" needs none:

toolkit = noclick.Toolkit(["gmail", "slack.send_message_to_channel", "exa.search"])
print(toolkit.tools)

Or pin it to one user, with only some of a connection's operations:

gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True, user="user_42")
toolkit = noclick.Toolkit([gmail.only("send_email_message"), "fetch"], user="user_42")

To test your loop without touching anyone's accounts, let a simulated world answer every call. The definitions are the real ones; the toolkit is one world, so what one call creates the next one finds, for 30 minutes after its last call, and each answer is billed as AI at NoClick's model price, as a test run's world is:

toolkit = noclick.Toolkit(["gmail", "stripe"], simulated=True, scenario="Ana has two unpaid invoices")
result = toolkit.call("stripe__list_invoices", {"status": "open"}, user="user_42")
print(result.text)   # made up, consistent with the scenario; nothing was read or sent
Attributes
refslist[Any]
simulatedbool
scenariostr | None
toolslist[str]

The refs as names (a Connection known only by id shows its id until used).

mcp_server

toolkit.mcp_server(name) -> McpServer

These tools as a hosted MCP server called name, in the toolkit's user (McpServer.from_name with create_if_missing=True). Sends nothing yet.

Parameters
namestrrequired
Returns
McpServer

The server, created on first use (or brought in step with this toolkit's tools); its url is what you give an MCP client.

Usage
toolkit = noclick.Toolkit(["hubspot", "search"], user="acme")
server = toolkit.mcp_server("crm-tools")
print(server.url)

definitionsasync

toolkit.definitions(format='anthropic', *, user=...) -> list[dict[str, Any]]

Describe the toolkit's tools for your LLM API (format = "anthropic", "openai" or "mcp"). Cached per format.

Parameters
formatstrdefault 'anthropic'
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
Returns
list[dict[str, Any]]

The tool definitions in that API's shape, ready to pass as tools. The first call for a format fetches them; later calls reuse them.

Usage

For Claude, the default format:

toolkit = noclick.Toolkit(["gmail", "search"])
with noclick.as_user("user_42"):
    tools = toolkit.definitions()
print([tool["name"] for tool in tools])   # ['gmail__send_email_message', ..., 'search']

For OpenAI, or for an MCP server of your own:

toolkit = noclick.Toolkit(["gmail", "search"])
openai_tools = toolkit.definitions("openai", user="user_42")
mcp_tools = toolkit.definitions("mcp", user="user_42")

callasync

toolkit.call(name, arguments=None, *, approval_id=None, user=...) -> ToolResult

Run one tool by its wire name (gmail__send_email_message for the connection named gmail, search). A policy refusal or a hold is an is_error result, never an exception; a held call goes through once approved when it is repeated with the same arguments (approval_id= names the approval explicitly).

Parameters
namestrrequired
argumentsdict[str, Any] | Nonedefault None
approval_idstr | Nonedefault None
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
Returns
ToolResult

The tool's content (text as one string) and whether it is_error. A call that couldn't run says why in content, with needs_connection or approval_required set when that's the reason.

Usage

Run a tool by the name the model uses:

toolkit = noclick.Toolkit(["gmail", "search"])
result = toolkit.call("gmail__fetch_emails_from_inbox", {"max_results": 5}, user="user_42")
print(result.is_error, result.text)

When the user hasn't connected the account yet, the result carries the link to send them:

toolkit = noclick.Toolkit(["gmail"])
result = toolkit.call("gmail__fetch_emails_from_inbox", {"max_results": 5}, user="user_42")
if result.needs_connection:
    print(result.connect_url)

handleasync

toolkit.handle(
    content, *, user=..., on_approval=None, on_connect=None
) -> list[dict[str, Any]]

Run every Anthropic tool_use block (from a Message, its content list, or dicts) concurrently. A tool your policy denies or holds for approval comes back as an error result; on_approval(result) (sync or async) receives each held call's ToolResult, in call order, and on_connect(result) each one whose needs_connection is set, so your app can show the user the connect link instead of relying on the model to pass it on. Each result carries the model's call_id and the arguments.

Parameters
contentAnyrequired
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
on_approvalOnApproval | Nonedefault None
on_connectOnConnect | Nonedefault None
Returns
list[dict[str, Any]]

One tool_result block per tool_use, in order: the content of the next user turn.

Usage

Run the model loop with Claude:

import anthropic

claude = anthropic.Anthropic()
toolkit = noclick.Toolkit(["gmail", "search"])
messages = [{"role": "user", "content": "Email Ana the notes from today's standup"}]
with noclick.as_user("user_42"):
    while True:
        response = claude.messages.create(
            model="claude-sonnet-5", max_tokens=1024, tools=toolkit.definitions(), messages=messages,
        )
        if response.stop_reason != "tool_use":
            break
        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": toolkit.handle(response)})

The model never sees who approves a held call or the approval link. Pass on_approval to get them:

toolkit = noclick.Toolkit(["gmail"])
blocks = toolkit.handle(response, on_approval=lambda result: notify_approver(result.approval_required))

Show the user a Connect button when a tool needs their account:

toolkit = noclick.Toolkit(["gmail", "google_calendar"])
blocks = toolkit.handle(
    response, user="user_42",
    on_connect=lambda result: show_connect_button(result.needs_connection["integration"], result.connect_url),
)

handle_openaiasync

toolkit.handle_openai(
    tool_calls, *, user=..., on_approval=None, on_connect=None
) -> list[dict[str, Any]]

Run OpenAI tool calls (a ChatCompletion, its message, a tool_calls list, or Responses function_call items). on_approval and on_connect work as in handle.

Parameters
tool_callsAnyrequired
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
on_approvalOnApproval | Nonedefault None
on_connectOnConnect | Nonedefault None
Returns
list[dict[str, Any]]

One {"role": "tool"} message per call, in order, or a function_call_output item for each Responses call. A call whose arguments aren't valid JSON isn't run; its message tells the model so.

Usage
from openai import OpenAI

gpt = OpenAI()
toolkit = noclick.Toolkit(["gmail", "search"])
messages = [{"role": "user", "content": "Summarize the emails Ana sent me this week"}]
with noclick.as_user("user_42"):
    while True:
        completion = gpt.chat.completions.create(
            model="gpt-5.1", tools=toolkit.definitions("openai"), messages=messages,
        )
        message = completion.choices[0].message
        if not message.tool_calls:
            break
        messages.append(message)
        messages += toolkit.handle_openai(completion)

ToolResult

class ToolResult

The outcome of one tool call. An unconnected account is a normal result whose needs_connection names the connection, its integration and the connect_url to send the user; a call held for approval carries approval_required: approval_id, request_id, approve_url, operation and approver ("user" or "owner"). request_id (the request a person answers), approve_url and operation (the approval's operation object) are None unless the request is addressed to you; the approver is emailed either way.

Attributes
namestr
contentAny
is_errorbool
needs_connectiondict[str, Any] | None
rawdict[str, Any]

The response JSON exactly as the API sent it.

approval_requireddict[str, Any] | None
argumentsdict[str, Any] | None

The arguments the call was made with, as the model gave them.

call_idstr | None

The model's id for the call (its tool_use or tool call id) when it came through handle or handle_openai; None from call.

connect_urlstr | None
approvalHumanRequest | None

The approval a held call waits on, as a request: wait() gives True once approved. None unless the call was held and the request is addressed to you.

approve_urlstr | None

The approval link, when the call waits for your approval; once approved, repeat the identical call.

textstr

The content as one string (what an OpenAI tool message carries).