Your model loop, your users' tools
Run your own model loop with each of your users' Gmail, Calendar and web search as tools, approvals and connect links included, then serve the same tools to Claude Desktop and Cursor.
pip install noclick openai
python 02_agents_in_your_product/your_llm_your_users_tools.pyNOCLICK_API_KEYUSER_EMAILYou already run the model: your prompts, your loop, your provider. What you don't want to build is the rest: an OAuth app per service, token storage and refresh for every user, an API client per integration, and a way to stop the model sending email nobody approved. A toolkit is that part. You list the tools, and it gives your model their definitions, runs each call in the right user's account, and hands back results in your model API's shape.
In this example a user asks for a morning brief: what's on their calendar tomorrow, which emails still need a reply, sent to them by email. You'll see the loop, what happens when the user hasn't connected an account yet, a send held for their approval, and the same tools served to Claude Desktop and Cursor as an MCP server.
You play the user. USER_EMAIL is your own address: approval requests go there.
pip install noclick openai
export NOCLICK_API_KEY=nk_live_...
export USER_EMAIL=you@example.com
python 02_agents_in_your_product/your_llm_your_users_tools.pyimport os
import time
from dataclasses import dataclass, field
from datetime import date
from openai import OpenAI
import noclickOne toolkit for every user
Each entry names a connection and one of its operations: gmail and calendar are
connections every user of your product has, and a name resolves among the connections of the
user a call acts for. So this one toolkit, built once at import, reads Ana's Gmail for Ana and
Bo's for Bo. exa.search needs no account and runs on NoClick's key.
Naming single operations keeps the model to what the feature needs: it can read mail and the calendar and send one kind of email, and nothing here deletes or archives.
TOOLKIT = noclick.Toolkit([
"gmail.fetch_emails_from_inbox",
"gmail.fetch_email_thread",
"gmail.send_email_message",
"calendar.list_calendar_events",
"exa.search",
])
CONNECTIONS = {"gmail": "gmail", "calendar": "google_calendar"}The model
Any OpenAI-compatible client works. This one uses NoClick's own
endpoint, so the model is charged to your NoClick account and needs no
provider key. To use your own OpenAI key instead, build OpenAI() and pick an OpenAI model id;
nothing else in the loop changes.
llm = OpenAI(base_url=noclick.AI.base_url, api_key=os.environ["NOCLICK_API_KEY"])
MODEL = "openrouter/anthropic/claude-sonnet-4.6"
MAX_MODEL_CALLS = 12
SYSTEM = f"""You are the assistant inside a productivity app. Today is {date.today():%A %d %B %Y}.
You can read the user's Gmail and Google Calendar and search the web.
Sending an email waits for the user's approval. When a tool says a call is waiting, tell the user
and stop; they will tell you when to go on. When a tool says an account isn't connected, say so."""Onboard a user
Do this when someone signs up: the user, under your id for them, and a
pending connection for each account the feature uses. Pending is fine:
a tool whose account isn't connected yet answers with the link to connect it. from_id with
create_if_missing=True also brings an existing user's name and email in step with yours.
The policy is the safety net. A require_approval rule holds every
gmail.send_email_message call until a person approves that exact call, whoever makes it: your
loop, an agent, or an MCP client. The rule names the integration's operation, not your
connection's name. Policy.add adds the one rule and leaves the rest of the policy as it is, so
it's safe to run every time. It's added for this user alone, so the example leaves your
account's policy alone; in a real product you'd add it to the account once.
A call made for a user is approved by that user, by email, which is why the user needs an address.
def onboard(user_id: str, email: str) -> noclick.User:
user = noclick.User.from_id(user_id, create_if_missing=True, name="Ana Lima", email=email)
with noclick.as_user(user):
for name, integration in CONNECTIONS.items():
noclick.Connection.from_name(
name, integration=integration, create_if_missing=True, reason="for your morning brief",
).hydrate()
noclick.Policy.add("gmail.send_email_message", to="require_approval")
return userThe loop
This is the usual OpenAI tool loop. Two NoClick lines do the work:
TOOLKIT.definitions("openai")gives the tool definitions, as{connection}__{operation}functions (gmail__send_email_message).TOOLKIT.handle_openai(completion)runs every tool call in the completion at once, inside the useras_usernames, and returns thetoolmessages to append.
A tool call never raises for something the model can deal with. An account that isn't connected,
or a call held for approval, comes back as an error result the model reads and explains. Your
code hears about both too, so your UI can show a real button rather than a link in the text:
on_approval receives each held call, and on_connect each call whose connection needs
connecting. The model never sees the approval link, so it can't approve its own call.
as_user covers NoClick's client, not the openai one. noclick.AI.headers() inside it gives
the headers that make the model's own tokens count toward this user's spend cap, like the tool
calls do.
@dataclass
class Turn:
reply: str
held: list = field(default_factory=list)
to_connect: dict = field(default_factory=dict)
def run_turn(user: noclick.User, messages: list) -> Turn:
turn = Turn("")
needs: list = []
with noclick.as_user(user):
headers = noclick.AI.headers()
for _ in range(MAX_MODEL_CALLS):
completion = llm.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLKIT.definitions("openai"),
extra_headers=headers,
)
message = completion.choices[0].message
messages.append(message)
if not message.tool_calls:
turn.reply = message.content or ""
turn.to_connect = {result.needs_connection["connection"]: result.connect_url for result in needs}
return turn
messages += TOOLKIT.handle_openai(completion, on_approval=turn.held.append, on_connect=needs.append)
raise RuntimeError(f"No answer after {MAX_MODEL_CALLS} model calls")
def say(user: noclick.User, messages: list, text: str) -> Turn:
messages.append({"role": "user", "content": text})
turn = run_turn(user, messages)
print(f"\nassistant: {turn.reply}")
return turnConnect, then approve
The first time, the accounts aren't connected yet, so the model says so and your code gets each
connection's link. In your product these are "Connect Gmail" and "Connect Google Calendar"
buttons; here they're printed, and wait() returns once you've connected each one. Then the
user says "try again", and the model does.
When the model sends the brief, the call is held. The approval request is already in the user's
inbox, and your code has it as result.approval, a human request with its
link. wait() on it returns True once they approve. The model's next attempt at the identical
call goes through, once: a changed recipient or body would need a new approval.
def connect_accounts(user: noclick.User, links: dict) -> None:
for name, url in links.items():
print(f"Connect {name}: {url}")
with noclick.as_user(user):
for name in links:
noclick.Connection.from_name(name).wait()
def approve(user: noclick.User, messages: list, held: list) -> None:
for result in held:
print(f"\n{result.name} is waiting for your approval: {result.approve_url}")
say(user, messages, "Approved, go ahead." if result.approval.wait() else "I declined that. Don't send it.")The same tools in Claude Desktop and Cursor
Your users also work in chat apps and editors that speak MCP. A hosted
MCP server puts the same toolkit at one URL for them, and NoClick runs
it: no MCP code of yours, no process to keep up. TOOLKIT.mcp_server(name) creates the server in
the user as_user names, over that user's connections, and after that keeps its tools in step
with the toolkit, so adding an entry above reaches every user's server the next time this runs.
The URL stays the same.
In your product this is a "Use your assistant in Claude" page: the URL, and how to add it. Ana adds it once:
- Claude Desktop: Settings, Connectors, Add custom connector, then paste the URL.
- Cursor: in
.cursor/mcp.json,{"mcpServers": {"assistant": {"url": "<url>"}}}. - Claude Code:
claude mcp add --transport http assistant <url>.
Every call through it is the same as a toolkit call: it runs in Ana's accounts, under the same policy, so a send from Claude Desktop waits for her approval by email just like one from your loop, and the client is told it's waiting. Each call is recorded with the server's name, so the console shows what her MCP client did.
The path of the URL is its secret: anyone who has it can call Ana's tools. Show it only to her,
and server.rotate() when she asks for a new one.
Whether it works has two sides. tool_count above zero means NoClick is serving tools. A
connection that isn't connected serves none, which is why this step comes after the user has
connected. clients() lists the MCP clients that have connected, as they named themselves, with
how many tools each saw; most connect, and list tools, only when a new chat or session starts.
def serve_over_mcp(user: noclick.User) -> noclick.McpServer:
with noclick.as_user(user):
server = TOOLKIT.mcp_server("assistant")
print(f"\nMCP server for {user.id}: {server.url} ({server.tool_count} tools)")
return server
def wait_for_a_client(server: noclick.McpServer, minutes: int = 10) -> None:
print("Add it to Claude Desktop or Cursor and start a new chat. Waiting for a client...")
deadline = time.monotonic() + minutes * 60
while time.monotonic() < deadline:
clients = server.clients()
if clients:
for client in clients:
print(f" {client.name} {client.version or ''} connected, sees {client.tool_count} tools")
return
time.sleep(5)
print(" No client yet. It'll connect whenever one starts a session; the URL doesn't expire.")
def main() -> None:
user = onboard("user_42", os.environ["USER_EMAIL"])
messages: list = [{"role": "system", "content": SYSTEM}]
turn = say(
user,
messages,
"What's on my calendar tomorrow, and which emails from the last two days still need a reply "
f"from me? Email me the brief at {user.email}.",
)
if turn.to_connect:
connect_accounts(user, turn.to_connect)
turn = say(user, messages, "I've connected them. Try again.")
approve(user, messages, turn.held)
server = serve_over_mcp(user)
wait_for_a_client(server)
if __name__ == "__main__":
main()What it costs
The model's tokens are charged to your NoClick account at the model's price (with your own key,
to your provider). Each tool call is billed as a tool run, exa.search included. The MCP server
itself is free; its calls bill like toolkit calls. All of it counts toward the user's spend cap
(spend_cap_usd_per_month on the user), so one heavy user can't run up your bill.
Clean up
Deleting the server stops its URL; the connections stay. Deleting a connection disconnects NoClick
from the account (keep gmail if you're still using it for connect_a_user.py):
with noclick.as_user("user_42"):
noclick.McpServer.from_name("assistant").delete()
noclick.Connection.from_name("calendar").delete()
noclick.Connection.from_name("gmail").delete()
noclick.Policy.remove("gmail.send_email_message")