Skip to main content

noclick

Python client for the NoClick Platform API: act for your users (the people and companies your product acts for, each known by your own id for them), connect their accounts, give your LLM tools, reach a human, run hosted agents, and give them inboxes, phone numbers and sandboxes. It works with NoClick's hosted platform; a self-hosted instance doesn't serve this API yet. From 0.2 the noclick package is this client; the workflow client it replaces stays at 0.1.x.

pip install noclick
export NOCLICK_API_KEY=nk_live_...          # a platform key with the scopes you need
# NOCLICK_API_URL defaults to https://api.noclick.com

Quick tour

import noclick

user = noclick.User.from_id("user_42", create_if_missing=True, email="ana@example.com")

with noclick.as_user(user):
    # A named connection: pending until Ana connects her Gmail at connect_url
    gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True,
                                         redirect_url="https://acme.ai/done")
    print(gmail.connect_url)                 # send your user here
    gmail.wait()                             # blocks until they've connected

    toolkit = noclick.Toolkit(["search", "gmail.send_email_message"])   # the connection named gmail
    ok = user.human.approve("Send the weekly summary to your team?").wait()   # True / False

    # Ana's own agent: uses her connections; may ask her for a calendar when it needs one
    assistant = noclick.Agent.from_name("assistant", create_if_missing=True, instructions="You run Ana's day.",
                                        tools=[gmail, "search"], can_request=["google_calendar"])
    print(assistant.message("What's on today?").text)

Ideas

  • Lazy objects. User.from_id(...), Connection.from_name(...), Agent.from_name(...), Operation.from_id(...) send nothing until used; create_if_missing=True creates on first use (a concurrent create is adopted, not duplicated). Agent.from_name also updates an existing agent whose definition differs from the code, keeping the grants its user gave it.
  • Named connections. Code, toolkits and agents name a connection ("gmail", or a Connection), so they always act in exactly that account; two accounts on one service are two names. An agent's connections are its own scope's: a user's agent uses the user's, an account agent the account's. A name that is no connection there names an integration's operations that need no account, on NoClick's key: tools=["exa.search"] and noclick.run("exa.search", {...}) work for every user with nothing connected, and a connection named exa holding your own key takes over the same refs.
  • Blocking or async, same method. Every method blocks; .aio is the coroutine: await agent.message.aio("hi"). Blocking calls run on a private loop thread, so they also work in Jupyter and inside running event loops.
  • Users, by your id. A user's id is your own id for them: 1 to 255 printable ASCII characters, without spaces or /, other than all and none. with noclick.as_user(user_or_id): (or async with) scopes every call inside it, sending the id as is in the NoClick-User header; any method's user= wins. Objects a user owns (its connections and agents) always act for that user; shared objects (Toolkit, Human(), account-level agents) follow the user current at each call, so one module-level toolkit serves every user (its connection names resolve among each call's user's).
  • Operations. Anything that waits on the world returns an Operation; .wait(timeout=) long-polls and returns the typed outcome (Connection, Answer, bool, AgentReply) or raises the typed error. .events() yields status changes, .cancel() cancels.
  • Errors carry .type, .message, .next_step and .details (read as attributes): noclick.NeedsConnection and noclick.CredentialDisconnected have .connection and .connect_url, noclick.Conflict has .existing_id, noclick.PaymentRequired has .balance_usd and .required_usd (dollars), plus noclick.ApprovalRequired, noclick.PermissionDenied, noclick.NotFound, noclick.RateLimited, ...
  • Safe retries. 408/429/5xx and connection errors retry with backoff (honouring Retry-After); each write carries one Idempotency-Key for all its attempts. A POST's 500 is not retried: the server replays it for that key.

Tools for your LLM

import anthropic

toolkit = noclick.Toolkit(["gmail", "search"])
messages = [{"role": "user", "content": "Email Ana the lemon cake recipe"}]
with noclick.as_user("user_42"):
    while True:
        response = anthropic.Anthropic().messages.create(
            model=MODEL, max_tokens=1024, tools=toolkit.definitions(), messages=messages)
        if response.stop_reason != "tool_use":
            break
        messages += [{"role": "assistant", "content": response.content},
                     {"role": "user", "content": toolkit.handle(response)}]

OpenAI: toolkit.definitions("openai") and messages += toolkit.handle_openai(completion) (Responses API function_call items work too). Connection tools are named {connection}__{operation}. A tool whose connection has no account yet returns a normal result telling the model so, with the link; toolkit.call(...) exposes it as result.needs_connection / result.connect_url.

A tool call never raises for policy: a denied call, or one held for approval, comes back as an error result the model reads. A hold's details are kept out of the model's view, in result.approval_required (approval_id, request_id, approve_url, operation, 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. The handle helpers hand each held call to on_approval:

blocks = toolkit.handle(response, on_approval=lambda r: notify(r.approval_required))

Once the person approves, the model's retry of the identical call goes through.

No model key of your own? noclick.AI runs chat, embeddings, images and transcription paid from your NoClick balance, and noclick.AI.base_url is an OpenAI-compatible endpoint (chat completions, responses, embeddings, models) for any OpenAI client, where your agents are models too:

reply = noclick.AI.chat(messages, tools=toolkit.definitions("openai"))   # OpenRouter ids: openrouter/...
answer = noclick.AI.chat("Where's my refund?", model="agent:support")   # conversation=answer.conversation continues it

More

slack = noclick.Connection.from_name("slack", user="user_42")
slack.lookup("channel", search="gen")                                # options for an ID field
gmail.run("send_email_message", {"to": ["a@b.c"], "subject": "Hi", "body": "..."})
slack.operations(search="message"); slack.operation("send_message_to_channel").parameters   # what it can do
noclick.Connection.integrations("mail")                              # find a service: name, label, connect_with
noclick.run("exa.search", {"query": "..."})                          # needs no account: no connection, NoClick's key
noclick.run("gmail.send_email_message", {...}, user="user_42")       # the same ref runs through a connection named gmail
gmail.request(reason="to send your summaries").wait()                # emails a fresh link to the user
gmail.token().access_token                                           # OAuth only, credentials:token scope
noclick.Connection.create("crm", credential_type="affinity_api_key", data={"api_key": "..."})   # a key you hold
for grant in assistant.grants():                                     # connections its user gave it
    print(grant.connection, grant.status)                            # assistant.revoke_grant(grant)
noclick.Human(email="ops@acme.ai").ask("Ship it?", options=["Yes", "No"]).wait().choice
reply = agent.message("Any lemon cake?")
agent.message("Two slices please", conversation=reply.conversation)   # same thread
agent.wake(reply.conversation, after=3600, note="Check the order shipped.")   # a turn later; agent.wakeups(), its wake_me tool
agent.memory.save("menu", "Lemon cake on Fridays.", description="What we bake when")   # it sees the line every turn
coder = noclick.Agent.create("coder", instructions="...", harness="claude_code", model_connection="claude")   # Agent.harnesses() lists them

run = noclick.Workflow.from_name("order-intake").run({"order_id": "o1"}).wait()   # one built in the app
print(run.outputs)
client = noclick.Client(api_key="nk_live_other", base_url="https://api.noclick.com")
noclick.User.list(client=client)                                        # or noclick.configure(api_key=...)

Spend, policy, channels and sandboxes

# Spend and caps
noclick.usage(group_by="user")["user_42"].usd             # this month, per user / key / agent
# any call can raise noclick.SpendCapExceeded (402) with .cap, .spent, .scope

# Policy: deny wins, a user policy narrows the account's
noclick.Policy.set(deny=["*.delete_*"], require_approval=["gmail.send_email_message"])
noclick.Policy.set(allow=["search", "gmail.*"], user="user_42")
try:
    gmail.run("send_email_message", args)
except noclick.ApprovalRequired as pending:                    # a Human.approve went out
    if pending.operation.wait():                               # True once approved
        gmail.run("send_email_message", args, approval_id=pending.approval_id)
# a Toolkit never raises: a denied or held tool is an error result (see "Tools for your LLM")

noclick.Brand.set(name="Acme", logo_url="https://acme.ai/logo.png", color="#ff5500")   # /h/ pages + emails
user.verify_phone(); user.check_phone("123456")      # True; a wrong code raises noclick.InvalidRequest

# Agents with tools, children and A2A
planner = noclick.Agent.create("planner", instructions="...", tools=["gmail", "slack.send_message_to_channel"],
                               budget_usd_per_month=20)            # the account's gmail and slack connections
child = noclick.Agent.create("inbox-helper", instructions="Summarize the inbox.", parent_id=planner,
                             tools=["gmail.fetch_emails_from_inbox"], ttl_s=900, budget_usd_per_month=0.5)
planner.children()                                        # child too; expired past its ttl_s, readable 30 days
researcher = planner.spawn("Find three launches.", tools=["gmail"], conversation="launch",
                           updates=["blocked", noclick.Before("gmail.*"), noclick.Every(900)])   # starts at once
researcher.job.wait(); researcher.job.updates(); planner.jobs(status="live")   # each update wakes planner in "launch"
researcher.ask("Where are you?"); researcher.ask("Skip B.", interject=True)   # a snapshot, or into its running turn
planner.a2a_card(); planner.a2a_url                       # JSON-RPC endpoint, platform key as bearer

# Channels: agents, the user and remote A2A agents in one room (closed on exit)
with noclick.Channel.create("launch", members=[planner, user, "https://remote.ai/a2a"],
                            mode="mentions", max_agent_turns=20, budget_usd=2) as room:
    room.post("@planner draft the announcement", as_=user)
    for message in room.stream():                        # long-polls until the channel closes
        print(message.author.name, message.text)          # async: `async for m in room.stream.aio()`
        if message.author.name == "planner":
            break

# Sandboxes (terminated on exit, billed per second)
with noclick.Sandbox.create(image="python", timeout_s=600) as sb:
    sb.files.write("/tmp/data.csv", csv_bytes)
    result = sb.exec("python -c 'print(open(\"/tmp/data.csv\").read()[:80])'")
    print(result.exit_code, result.stdout, result.stderr)  # a non-zero exit is a result, not an error

Sessions, inboxes and phone numbers

# User sessions: a short-lived token pinned to one user, safe for a browser
session = user.session(scopes=["agents", "humans", "inbox"], ttl_s=3600)   # max 24h
session.token, session.expires_at                             # repr never shows the token
user_client = noclick.Client.for_session(session)           # same as noclick.Client(api_key=session.token)
noclick.Agent.list(client=user_client)                      # acts as that user, with those scopes only

# Agents can search and fetch; every agent can also run shell commands (a policy can deny `code`)
researcher = noclick.Agent.create("researcher", instructions="...", tools=["search", "fetch", gmail])

# Channels: list them; remote A2A agents can carry auth headers (stored encrypted, never shown back)
noclick.Channel.list(status="open", limit=20)
room.add(noclick.A2AMember("https://remote.ai/a2a", headers={"Authorization": "Bearer ..."}))
room.members[0].a2a_header_names                         # ["Authorization"]: names only

# Triggers and subscriptions: a URL or an app's events wake an agent, or post in a room as the app
orders = researcher.add_trigger(noclick.OnWebhook(name="orders", message="A new order arrived."))
print(orders.url)                                        # each POST is a turn
researcher.add_trigger(noclick.OnEvent("slack", "app_mention"))   # slack.events() lists the events
room.subscribe(noclick.OnEvent("stripe", "invoice_payment_failed"), wake=[planner])

# Workspaces: a company's channels, agents and people under one name, with a monthly cap
acme = noclick.Workspace.from_name("Acme", create_if_missing=True, members=[planner, noclick.User.from_id("ana")],
                                   budget_usd_per_month=200)
billing = noclick.Channel.create("billing", workspace=acme, members=[planner, noclick.User.from_id("ana")])
billing.post("@planner refund order 42", as_=noclick.User.from_id("ana"))   # each person as themselves
acme.subscribe(noclick.OnWebhook(name="deploys"), channels="all", wake="all")  # one source, every room

# Inboxes: an address your code or agent receives mail at
inbox = noclick.Inbox.create(local_part="signup", agent=researcher)   # agent= wakes it per message
print(inbox.address)
mail = inbox.wait_for(sender="noreply@github.com", subject_contains="code", timeout_s=300).wait()
print(mail.text, mail.attachments)                       # documents come with their text
mail.reply("Thanks!")                                    # to the sender only; no free-form outbound
for message in inbox.stream():                           # long-polls until you break
    ...

# Phone numbers (US local, monthly; noclick.NotAvailable outside the phone feature)
[pick] = noclick.PhoneNumber.available(area_code="415", limit=1)
number = noclick.PhoneNumber.buy(pick, max_monthly_usd=5)     # noclick.PriceExceeded above the ceiling
number.answer_with(host)                                      # inbound calls reach this agent
call = number.call("+14155550100", goal="Confirm Friday's 3pm booking.").wait()
print(call.text, call.seconds, call.cost_usd)           # call.transcript: [{role, text, at}, ...]
number.calls(); number.release()

# Regions: where a sandbox or an agent runs, for good (else your account's default)
print([r.name for r in noclick.regions()])                # ['us-east', 'us-west', 'eu', 'ap']
with noclick.Sandbox.create(region="eu") as sb:
    print(sb.region)

# Hosted MCP servers: a user's tools at one URL, for Claude, ChatGPT or Cursor
server = noclick.McpServer.from_name("crm-tools", tools=["hubspot", "search"], create_if_missing=True, user="acme")
print(server.url, server.tool_count, [c.name for c in server.clients()])

# A repository in a coding agent's workspace; NoClick refuses a push outside agent/*
github = noclick.Connection.from_name("github", integration="github", create_if_missing=True)
fixer = noclick.Agent.from_name("fixer", create_if_missing=True, instructions="Fix the failing tests.",
                                harness="claude_code", model_connection="claude",
                                repos=[github.checkout("acme/web", push="agent/*")])

# Sandboxes: long commands in the background, within the sandbox's own lifetime
with noclick.Sandbox.create(timeout_s=3600) as sb:
    build = sb.exec("make all", background=True, timeout_s=1800)   # an Operation, up to 3600s
    print(build.wait().exit_code)                        # ExecResult, same as the blocking form

# Volumes: a folder that outlives the sandbox or turn that wrote it; buckets: files by key with signed links
data = noclick.Volume.from_name("datasets", create_if_missing=True)
data.write("/raw/orders.csv", b"id,total\no_1,42\n")
with noclick.Sandbox.create(volumes={"/data": data}) as sb:
    print(sb.exec("wc -l /data/raw/orders.csv").stdout)
uploads = noclick.Bucket.from_name("uploads", create_if_missing=True)
uploads.put("invoices/0042.pdf", b"%PDF-1.7 ...")
print(uploads.signed_url("invoices/0042.pdf", expires_in=3600))
files = noclick.Agent.from_name("filer", create_if_missing=True, instructions="File what users send you.",
                                tools=[uploads.only("put", "signed_url").bind(prefix="users/{{user.id}}/")])
print(files.workspace(conversation="ticket-1841", user="acme").files())   # what it wrote in one conversation

# Secrets: write-only values in a sandbox's or an agent's shell environment
shipping = noclick.Secret.from_name("shipping", values={"SHIPPING_API_KEY": "sk-..."}, create_if_missing=True)
with noclick.Sandbox.create(secrets=[shipping], network=True) as sb:
    sb.exec("python track.py")                           # reads os.environ["SHIPPING_API_KEY"]

# Dicts: shared key-value state; queues: work delivered at least once, to code or to an agent
seen = noclick.Dict.from_name("processed-orders", create_if_missing=True)
if seen.put_if_absent("evt_9f2c", True, ttl_s=7 * 86400):
    print("first delivery", seen.increment("handled-today"))
jobs = noclick.Queue.from_name("invoices", create_if_missing=True)
jobs.put({"invoice": "INV-1042"}, dedupe_key="INV-1042")
for msg in jobs.iterate(stop_when_empty=True):
    print(msg.body)
    msg.ack()
chaser = noclick.Agent.from_name("chaser", create_if_missing=True, instructions="Chase unpaid invoices.",
                                 triggers=[noclick.OnQueue(jobs, concurrency=5)])   # or each message is a turn

Activity: operations, tool calls, requests, usage

# Pages: list() drains; list_page() returns one page and its cursor (every resource with a list)
page = noclick.Operation.list_page(kind="agent.message", status="live", limit=50)
more = noclick.Operation.list_page(kind="agent.message", status="live", cursor=page.next_cursor)

# Read across users without acting as one: user_filter is the user_id query, never the header
noclick.Agent.list(user_filter=noclick.ALL_USERS)         # noclick.NO_USER = account-level only
noclick.Agent.list(status="working", order="last_active")  # each: status, last_active_at, running_turns, child_count
noclick.Sandbox.list_page(user_filter="user_42")          # a User or your id for one

noclick.Operation.list(status="failed", created_after=datetime.now(timezone.utc) - timedelta(days=1))
noclick.Operation.summary(kind="agent.message", agent=planner).by_status   # {"running": 1, ...}
op = noclick.Operation.from_id("…")
op.wait_ms, op.run_ms, op.held_ms, op.transitions, op.tool_calls()
repr(op)   # on the hosted API: ..., console_url='https://noclick.com/dashboard/developers/operations?op=…'
# errors raised by op.wait() end with the same console link

noclick.ToolCall.list_page(tool="gmail.*", status="error", source="agent")
noclick.ToolCall.summary(since=since)                         # total, success, error
noclick.ApiRequest.list_page(method="POST", status="4xx", key="nk_live_ab12")   # writes and failures
noclick.HumanRequest.list_page(status="pending", kind="approve")
planner.conversations_page(user_filter=noclick.ALL_USERS)   # a user's own agent: owner sessions only (a key 404s; drop the filter)
noclick.Usage.series(group_by="agent")                        # daily lines this month; .limits() for caps
noclick.Policy.evaluate(["gmail.send_email_message"], draft={"layer": "account", "deny": ["gmail.*"]})
noclick.Policy.list(); noclick.Brand.reset()

# api_key may be a function (sync or async), asked on every attempt: a refreshing token
noclick.Client(api_key=lambda: session_store.current_token())

# Events, delivered to your server signed (Webhook.verify in your receiver), kept 30 days
hook = noclick.Webhook.from_name("https://api.yourco.test/noclick/events", create_if_missing=True,
                                 events=["operation.failed", "connection.*"])
noclick.Event.list(type="operation.failed", user_filter=noclick.ALL_USERS, limit=20)
planner.wake("launch", when="connection.connected", filters={"name": "gmail"}, note="Gmail is in: carry on.")
planner.add_trigger(noclick.OnPlatformEvent("operation.failed", filters={"kind": "agent.task"}))
room.subscribe("connection.disconnected")

# Charging your users: usage.recorded per step of their spend; pause them, in your words, when your billing says
user = noclick.User.from_id("user_42")
user.pause_spending(message="You've used your credits.", action=("Top up", "https://acme.test/top-up?u={{user.id}}"))
user.resume_spending()                                        # what they sent meanwhile runs now
noclick.User.list(spending="paused")

The noclick command

pip install "noclick[cli]", then noclick login (a browser approves it, and the key stays in your OS keychain), noclick agents chat, noclick bucket cp, noclick webhooks listen and more: The CLI.

Invite-only beta

These are open to accounts in the beta. Request access; without it, a call answers 404 (noclick.NotFound), or noclick.NotAvailable. The reference marks each one, parameters of other calls included.

  • noclick.Card: virtual cards with limits and approvals, for agents that buy things.
  • noclick.Machine: an always-on machine at a flat monthly price for agents that never sleep (machine= on an agent).
  • Phone numbers: the agent texting a caller (texts=, with US carrier registration), and moving a number in (port_in) or out (transfer_out).

Development

cd sdk/python
pip install -e ".[dev]"
python -m pytest -q

License

Apache License 2.0. The SDK is deliberately permissive so that the code you write against it stays under whatever license you choose. NoClick itself is AGPL-3.0-only.

Metadata

Release files for noclick 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for noclick 0.2.1
File Size Uploaded Provenance
noclick-0.2.1.tar.gz 514.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for noclick 0.2.1
File Interpreter ABI Platform
noclick-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 913.2 kB

Release files / noclick-0.2.1.tar.gz

Download URL noclick-0.2.1.tar.gz
Size 514.9 kB
Tags Source
SHA-256 checksum
How to use checksums
cfdb39f6a02efa785a6677f01b7f8d496a8abe3567b6dc490d2a8c19d0a10bdf
BLAKE2b-256 checksum
How to use checksums
417ff5961df9b533ab7a426f82ab9b66415ad49bbfad4c02f48348ab25be910f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.5

Release files / noclick-0.2.1-py3-none-any.whl

Download URL noclick-0.2.1-py3-none-any.whl
Size 398.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3d628cf72a4013e1c44861facb571b2adc0a4c40bf4a47641830d9270b6e6577
BLAKE2b-256 checksum
How to use checksums
dc64600485be385f2e1e8ad9bfdeebc0eabda01c15058a986e3a73a105e95576
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.5

Security for noclick 0.2.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page