Connections

Name each account your code and agents act in, let your users connect it once, and use it by name.

A connection is one account on another service, under a name you choose: Ana's Gmail as gmail, Acme's Stripe as stripe. Your code, your toolkits and your agents name the connection, so they always act in exactly that account. Your users connect their own accounts through a page NoClick hosts, and you never see or store their tokens. NoClick keeps OAuth tokens fresh and checks that each connection works.

Create a connection

import noclick

with noclick.as_user("user_42"):
    gmail = noclick.Connection.from_name(
        "gmail",
        integration="gmail",
        create_if_missing=True,
        reason="to send your weekly summaries",
        redirect_url="https://app.example.com/settings/connections",
    )
    print(gmail.status, gmail.connect_url)   # pending https://noclick.com/credential/provide/...

A new connection is pending: no account is connected yet, and connect_url is the page where the user signs in to the service. Show it in your product (a "Connect Gmail" button is typical). reason appears on the page, so tell the user why you're asking. When they finish, NoClick sends them to your redirect_url with status=success in the query string.

An operation that needs no account, like Exa's web search, needs no connection at all: name it "exa.search" in tools, or run it with noclick.run. A connection named exa holding your own key makes the same names use your key: see Operations on NoClick's key.

from_name is lazy: nothing is sent until you use the connection, and with create_if_missing=True that first use creates it if it doesn't exist, so the same line works for a new user and a returning one. noclick.Connection.create always creates, and raises noclick.Conflict if the name is taken.

A name is 1 to 32 lowercase letters, digits, - and _, unique for each user (and among your account's own connections). search, fetch and code are the built-in tools' names and can't be used.

Two accounts on one service

A user with two Gmail accounts has two connections:

with noclick.as_user("user_42"):
    work = noclick.Connection.from_name("work-gmail", integration="gmail", create_if_missing=True)
    home = noclick.Connection.from_name("home-gmail", integration="gmail", create_if_missing=True)

A toolkit or agent given both gets both sets of tools and picks per call.

Wait for the user

gmail.wait()           # returns once an account is connected
print(gmail.account)   # ana@example.com, when the provider says who it is

wait() returns at once when the connection is already connected. It waits on the connection, not on one link: an account connected through any of its links ends the wait, and when a link lapses after you made a fresh one (connect_link() or request(), from any process), it keeps waiting on the fresh one. It raises noclick.OperationExpired when the open link lapses and no fresh one was made. In a web server, don't block a request on it: check gmail.refresh().connected later, or wait in a background job.

request() emails a fresh link to the user. connect_link() makes one without sending it, or sends it where send_to says: "user", or, only from code that isn't acting for a user, "owner" (your account's owner) or another email address. A connection of your account's own has no user, so request() on one takes send_to="owner" or an email; its default, "user", raises noclick.InvalidRequest.

link = gmail.request(reason="to post your team's standup")
print(link.url)
print(link.delivered_via)   # ["email"]
print(link.undelivered)     # channels that failed, each with a reason

Links go out by email only. For a user with no email address, send_to raises noclick.InvalidRequest before anything is created: make the link without send_to and pass link.url on yourself. The new link is the connection's own from then on, so gmail.connect_url is its url. A link is also an operation: link.wait() returns the connected Connection.

Use it

gmail.run("send_email_message", {
    "to": ["ana@example.com"],
    "subject": "Your weekly summary",
    "body": "Three invoices were paid this week.",
})

Calling integrations covers what a connection can do (operations() and operation()), run and lookup. To hand a connection to a model, list it in a toolkit or in an agent's tools. A name there is a connection in the scope the tools run in, else the integration of that name, limited to its operations that need no account.

Share one across users

A connection belongs to one scope, a user or your account, and a name in an agent's tools always means the agent's own user's connection. To give an agent a connection from another scope, pass the Connection object itself: it stays that connection, wherever the agent lives. That's how you build what your product needs on top of users: a company whose people share its Slack, or your own support mailbox in every user's agent.

import noclick

team_slack = noclick.Connection.from_name("team-slack", user="acme")   # the company's, connected once
with noclick.as_user("dana"):
    assistant = noclick.Agent.from_name(
        "assistant",
        create_if_missing=True,
        instructions="You help Dana with her email, and post updates to her team.",
        tools=["gmail", team_slack.only("send_message_to_channel")],
    )

"gmail" is Dana's own connection; team_slack stays Acme's. Your account's own connections share the same way:

import noclick

support_mail = noclick.Connection.from_name("support-gmail")   # your account's
with noclick.as_user("user_42"):
    assistant = noclick.Agent.from_name(
        "assistant", create_if_missing=True,
        instructions="When the user needs a person, email our support team.",
        tools=[support_mail.only("send_email_message"), "search"],
    )

What holds when you share one:

  • Only your account decides. Only a key that acts for your whole account can put another scope's connection in an agent's, a toolkit's or a hosted MCP server's tools. A key pinned to a user, or a session, raises noclick.PermissionDenied, so no user can reach another's accounts on their own.
  • Tool names stay unique. The model sees a shared connection's tools under its name (team-slack__send_message_to_channel), so an agent can't hold two connections of one name: name company connections apart, like team-slack.
  • The owner approves. A call is judged by your account's approval rules and by the rules of the agent's user and the connection's user, and the strictest one decides. A held call is approved by the connection's owner: its user, or your account's owner for your account's connection.
  • The agent's user pays. Its calls count toward the spend cap of the user the agent acts for, and show in both users' activity.
  • Taking it back. Take it out of the tools, or delete or reconnect the connection itself. used_by on a connection lists every agent and hosted MCP server that has it in their tools, from any scope.

A toolkit takes the same objects. A toolkit you build once and use for every user keeps resolving names among each user's connections, while a shared connection stays itself: see Toolkits.

Fix arguments the model mustn't choose

An agent that reads email can be told, by an email, to send your user's data somewhere else. bind(...) on some of a connection's operations takes that choice away: a bound parameter leaves the tool's schema, so the model never sees or sets it, and NoClick fills it on every call.

import noclick

with noclick.as_user("user_42"):
    gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True)
    assistant = noclick.Agent.from_name(
        "assistant",
        create_if_missing=True,
        instructions="You read the user's inbox and email them a summary each morning.",
        tools=[
            gmail.only("fetch_emails_from_inbox"),
            gmail.only("send_email_message").bind(to="{{user.email}}"),
        ],
    )

A value is fixed (channel="#support"), or names the user the call acts for: {{user.id}}, {{user.email}}, {{user.name}} or {{user.metadata.<key>}}, from the user record. Anything else in {{...}} raises ValueError before anything is sent. What holds:

  • A bound value always wins, over what the model passes through in an operation's free-form parameters too (a bound key in an object the operation passes on as is, like Stripe's extra_params, is dropped).
  • A tool whose binding can't be filled isn't offered that turn: a user with no email gets no send_email_message, and an account conversation has no {{user...}} at all.
  • Policy judges the final arguments, bound ones included, so a rule's condition sees args.to as it will be sent.
  • It works wherever tools do: an agent's tools, a toolkit (which sends its entries with each call) and a hosted MCP server, whose {{user...}} is the server's user, so an account server doesn't serve a tool bound to one. A shared connection (invite-only beta) binds the same way: team_slack.only("send_message_to_channel").bind(channel="#announcements").

Every operation you list must take each parameter you bind (else noclick.InvalidRequest); bind again to add more. A bound operation is that operation's only entry: listing gmail (every operation, unbound) beside gmail.only("send_email_message").bind(...) raises noclick.InvalidRequest, so list the others one by one. On the wire each is {"tool": "gmail.send_email_message", "bind": {"to": "{{user.email}}"}}, and an agent's tools reads it back that way. Databases and buckets bind their operations the same way, to pin an agent to one table or one folder.

A repository on a git host (a github connection) can also be checked out in an agent's workspace, with pushes limited to the branches you name: see Work in a repo from an agent.

See what a user has connected

with noclick.as_user("user_42"):
    for connection in noclick.Connection.list():
        print(connection.name, connection.integration, connection.status)

Outside as_user, noclick.Connection.list() lists your account's own connections. To read every user's at once, pass user_filter=noclick.ALL_USERS; integration="gmail" narrows the list. noclick.Connection.get takes an id or a name.

A user's connections are private to them. They don't show up among your own connections in the NoClick app.

Accounts connected in the app

The accounts you connected in the NoClick app are connections too, so everything your account can use is one list. Outside as_user, noclick.Connection.list() ends with them, after your named connections: each one's origin is "app" (a named connection's is "api"), its name is the one it has in the app, and its id is credential: followed by its id, which is how you refer to it:

for connection in noclick.Connection.list(origin="app"):
    print(connection.id, connection.name, connection.integration, connection.access)

slack = noclick.Connection.from_id("credential:9b2a1c5e-7d9f-4a31-8f6d-3f1d2c4b8a7e")
slack.run("send_message_to_channel", {"channel": "#ops", "text": "Deployed."})
noclick.run("credential:9b2a1c5e-7d9f-4a31-8f6d-3f1d2c4b8a7e.list_channels")

They run, look up, verify and require approval like any connection, billed and recorded the same way. The app connects, reconnects and removes them, so connect_link, import_ and delete on one raise noclick.InvalidRequest. An account a teammate shared with yours is listed too, with access "view" or "edit" ("owner" for your own): it runs, but its token() and its approval rules stay its owner's. They belong to your account, never to one of your users: a call made with as_user doesn't see them.

An account agent takes one as a tool, the Connection itself or its id in tools, whole or one operation at a time:

slack = noclick.Connection.from_id("credential:9b2a1c5e-7d9f-4a31-8f6d-3f1d2c4b8a7e")
ops = noclick.Agent.from_name(
    "ops",
    create_if_missing=True,
    instructions="You post deploy notes for the team.",
    tools=[slack.only("send_message_to_channel"), "credential:9b2a1c5e-7d9f-4a31-8f6d-3f1d2c4b8a7e.list_channels"],
)

Each turn checks it again: once it's removed in the app or no longer shared with your account, its tools tell the agent they can't run, and once it's disconnected, that it needs reconnecting in the app. A user's agent can't take one, and a toolkit names connections only, since its tools go on the wire by a connection's name.

Require approval for an operation

A connection can require its owner to approve each call of an operation, wherever the account is used: your code, your agents, and the workflows in the app. require_approval adds operations, or "*" for every one, and never removes any; lifting one is done in the app:

gmail = noclick.Connection.from_name("gmail")
gmail.require_approval("send_email_message", "delete_message")
print(gmail.approval_operations)   # ['delete_message', 'send_email_message']

It needs an account to hold the rules: on a pending connection it raises noclick.InvalidRequest, so connect it first. A held call raises noclick.ApprovalRequired, as a policy hold does, and runs once its owner approves that exact call. Use a policy for rules about tools across your account; use this for one connected account that should never act unseen, whoever uses it.

From an app's preview

An app's preview only reads through its connections. An operation that may write in the account (anything not named as a read, such as list_, get_ or search_) raises noclick.PermissionDenied, naming the fix, so trying a branch never posts to a real channel or emails a real customer. Operations that run on NoClick's key act in no account of yours and run as usual. Production is unchanged.

When a connection holds a test account (a sandbox workspace, a test inbox), mark it, and previews write through it as production does:

slack = noclick.Connection.from_name("slack-sandbox")
slack.mark_test_account()
print(slack.test_account)   # True

mark_test_account(False) takes the mark back. Only the account that connected it marks it, and only once it holds an account.

Check that it works

status is pending until an account is connected, connected after, and disconnected once the account is revoked or its credential deleted. A pending connection still runs its operations on NoClick's key. A connected account can still fail (a revoked grant, a key for the wrong workspace), so verify() asks the provider now:

check = gmail.verify()
print(check.reachable, check.message)   # True  Signed in as ana@example.com.

reachable is True when the provider accepted the account, False when it refused it, and None when NoClick can't tell. samples holds a few things from the account, such as channel or repository names, and message says what was found. The result is also kept as gmail.verification, which stays None for a connection nobody has checked yet. There's nothing to ask about without an account: verify() on a pending connection raises noclick.NeedsConnection, and on a disconnected one noclick.CredentialDisconnected, each with the connect_url that fixes it (a connection of another scope's, say the account's from a user's code, raises noclick.NotAvailable).

Reconnect

A disconnected connection keeps its name. Send a new link, and once the user connects, the connection holds the new account and NoClick removes the old one. Everything that names the connection keeps working:

if gmail.refresh().status == "disconnected":
    gmail.request(reason="Gmail stopped working. Reconnect it to keep your summaries coming.")

A run through a disconnected connection raises noclick.CredentialDisconnected, which carries the reconnect link as connect_url.

Bring credentials you already hold

If you already have a user's API key or OAuth tokens, create the connection with them. NoClick validates the fields and probes the provider before saving, so a rejected key leaves nothing behind:

crm = noclick.Connection.from_name(
    "crm",
    integration="affinity",
    data={"api_key": "..."},
    create_if_missing=True,
    user="user_42",
)

Like any from_name, this creates the connection holding that key the first time and uses the existing one after (it never overwrites a key). noclick.Connection.create(..., data=...) always creates. import_ swaps in a new key later, a rotated key for example: crm.import_({"api_key": "..."}). When a service takes more than one kind of credential, credential_type= says which one you hold; Connection.integrations lists the types each integration accepts as credential_types.

Model providers

A model provider's connection holds what an agent on a coding harness thinks with: an API key, or a Claude or ChatGPT plan. It's a connection like any other, of an integration whose kind is "model" (anthropic, openai, openrouter, opencode and more): noclick.Connection.integrations(kind="model") lists them. The agent names it as its model_connection, never in its tools.

claude = noclick.Connection.create("claude", integration="anthropic", data={"api_key": "sk-ant-..."})
print(claude.verification)   # Anthropic accepted the key

data={"api_key": ...} holds your key, probed first like any key. Without data, the connection is pending and its link asks for the key. Where the provider's subscription is true, the link on a user's own connection also offers signing in with their plan instead: a Claude plan runs only claude_code, and a ChatGPT plan runs codex, opencode and hermes on the models the plan allows. A plan only connects at the link, and only on a user's own connection, so an account agent never runs on someone's personal plan:

with noclick.as_user("user_42"):
    plan = noclick.Connection.from_name("claude", integration="anthropic", create_if_missing=True)
    print(plan.connect_url)   # "Paste an Anthropic key" or "Sign in with Claude"

NoClick never hands out a model key or plan token: token() on a model provider's connection raises noclick.PermissionDenied.

A user's agent can think with your account's key: pass the account's Connection object as its model_connection, as you would share one in its tools. The same rules hold: only a key that acts for your whole account can set it, and a connection holding someone's plan is never shared, so a user's agent runs on your key or its user's own plan, never on another person's. The model calls are paid on that key; NoClick bills the agent's user for the rest of the turn.

import noclick

claude = noclick.Connection.from_name("claude")   # your account's Anthropic key
with noclick.as_user("user_42"):
    coder = noclick.Agent.from_name(
        "coder", create_if_missing=True, instructions="You fix the user's site.",
        harness="claude_code", model_connection=claude,
    )

Call the provider yourself

To call a provider's API directly with a user's OAuth connection, get a short-lived access token. This needs a key with the credentials:token scope:

token = gmail.token()
headers = {"Authorization": f"{token.token_type} {token.access_token}"}

You get access tokens only, never refresh tokens or stored API keys. For API-key connections, use operations instead. Like verify(), it needs an account: a pending connection raises noclick.NeedsConnection and a disconnected one noclick.CredentialDisconnected, each with its connect_url.

Delete

gmail.delete()

This deletes the connection and, where the provider supports it, disconnects NoClick from the account. Code and agents that name it stop working until a connection with that name exists again, and any agent it was granted to loses its tools.

used_by says beforehand what loses it: the agents holding it (in their tools, as their model_connection, or granted it by their user), the hosted MCP servers with it in their tools, and the apps that bind it, each with its kind (agent, mcp_server or project), id, name, user_id and an app's binding name:

gmail = noclick.Connection.from_name("gmail", user="user_42")
for holder in gmail.used_by:
    print(holder["kind"], holder["name"])
gmail.delete()