Toolkits
Give your own LLM calls tools that run in your users' connected accounts, plus web search, page fetching and a code sandbox.
If you run the model loop yourself, with Claude, GPT or anything else, a toolkit hands it NoClick's tools. You pick the tools; the toolkit gives you their definitions in your model API's format, runs the calls the model makes, and returns results in the shape the API expects back.
Pick the tools
import noclick
toolkit = noclick.Toolkit(["gmail", "slack.send_message_to_channel", "search"])A toolkit entry is one of:
| Entry | Gives the model |
|---|---|
"gmail", or a Connection | every operation of the connection named gmail |
"slack.send_message_to_channel" | one operation of the connection named slack |
"exa.search", or "exa" | an operation that needs no account, or all of them, on NoClick's key |
"search" | web search, with sources |
"fetch" | a web page's text |
"code" | Python or bash run in a fresh sandbox, up to 60 seconds |
A name is a connection first. When no connection has that name, it's the integration of that name, limited to its operations that need no account: "exa.search" runs on NoClick's key for every user, with nothing to create or connect. A connection named exa holding your own key takes over the same entry (Operations on NoClick's key). search and fetch stay the short way to give a model the web.
A toolkit holds at most 64 tools, so name single operations of a large integration: Slack has more than 200 and Stripe more than 400. When a connection's operations take IDs (a channel, a repository), the toolkit adds that connection's lookup_options tool so the model can find valid values.
The model sees each connection's tools as {connection}__{operation} (gmail__send_email_message), described with the service and the connection's name, so a toolkit with a work-gmail and a home-gmail connection gives the model both and lets it choose.
Run the loop with Claude
import anthropic
claude = anthropic.Anthropic()
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)})definitions() returns Anthropic tool definitions and caches them. handle() runs every tool_use block in the response at once and returns the matching tool_result blocks for the next user turn.
Run the loop with OpenAI
from openai import OpenAI
gpt = OpenAI()
messages = [{"role": "user", "content": "What's on my calendar tomorrow?"}]
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)handle_openai also takes Responses API function_call items, and returns function_call_output items for them. definitions("mcp") gives MCP tool definitions.
One toolkit for every user
A toolkit acts for whoever as_user names at the time of each call, and connection names resolve among that user's connections: "gmail" is Ana's gmail connection for Ana and Bo's for Bo. So build it once at module level and use it for all your users, as long as each of them has a connection by that name. An operation that needs no account needs no connection, so "exa.search" works for every user as it is. Create the others when you onboard them; a pending one is fine, since the model gets its link:
with noclick.as_user("user_42"):
noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True).hydrate()A Connection object in the list (or gmail.only("send_email_message") for some of its operations) is created first when it's lazy. One from the user the toolkit acts for stands for its name; one from another user, or your account's, stays that connection (invite-only beta: see Share one across users). To pin a toolkit to one user, pass user= when you build it.
What the model sees when a tool can't run
A tool call never raises for something the model can deal with. It comes back as an error result the model reads:
- The operation needs an account and the connection has none yet, or its account was revoked: the result says so and includes a link to connect it. Your model can pass the link on; you can read it from
result.connect_url, andresult.needs_connectionnames theconnectionandintegrationtoo. - The user has no connection by that name.
- Your policy forbids the tool.
- The call is held for a person's approval.
A held call's details (who approves it, the approval link) are kept out of the model's view. Pass on_approval= to see them, and on_connect= to get each result that needs a connection, so your app shows the user a Connect button instead of hoping the model passes the link on:
blocks = toolkit.handle(
response,
user="user_42",
on_approval=lambda result: notify_approver(result.approval_required),
on_connect=lambda result: show_connect_button(result.needs_connection["integration"], result.connect_url),
)Both take a function or a coroutine function, called once per result in call order, for handle and handle_openai alike. Each result carries the model's call_id for the call and the arguments it gave, so you can match it to the model's turn. When the approval is addressed to you, result.approval is its request: result.approval.wait() gives True once approved.
The approver is emailed either way. Once they approve, the model's retry of the identical call goes through.
Fix arguments the model mustn't choose
bind(...) on some operations takes a parameter out of the model's hands: NoClick fills it on every call, from {{user.email}} and the rest where you name the user. A toolkit sends the bound refs with each call, so it works the same as in an agent's tools. A bound operation whose binding can't be filled for the user the calls act for (no email, say, or your account with no user at all) is left out of definitions(), and a call to it comes back as an error result:
gmail = noclick.Connection.from_name("gmail")
toolkit = noclick.Toolkit([gmail.only("send_email_message").bind(to="{{user.email}}"), "search"])See Fix arguments the model mustn't choose for what a binding may name and how policy sees it.
Call a tool yourself
call runs one tool by the name the model uses ({connection}__{operation}, or a capability's name) and returns a ToolResult:
result = toolkit.call("gmail__fetch_emails_from_inbox", {"max_results": 5})
print(result.is_error, result.text)What it costs
Connection tools run in your users' own accounts. An operation that needs no account runs on NoClick's key, with a connection or without one. Both are billed as tool runs. search and fetch use NoClick's search provider, and code runs in a sandbox billed like any other; all three count toward your usage and the user's spend cap.
Serve it over MCP
toolkit.mcp_server("crm-tools") puts the same tools at one URL for Claude, ChatGPT, Cursor or any MCP client, in the toolkit's user: see Hosted MCP servers.
Simulate it
noclick.Toolkit([...], simulated=True) answers every call from a simulated world instead of your users' accounts, so you can test your loop without side effects: see Simulate your own tools.