Project
Projects: a repo deployed to a URL, with what the running app needs.
repo = noclick.Repo.from_name("booking", create_if_missing=True)
app = noclick.Project.from_name("booking", repo=repo, create_if_missing=True)
app.bind("DB", noclick.Database.from_name("booking-db", create_if_missing=True))
app.secrets.set("MAPS_API_KEY", "...")
deployment = app.deploy().wait()
print(deployment.url) # https://booking.noclick.siteA deploy of the production branch goes live at app.url; any other branch
gets a preview URL of its own. Bindings, secrets and schedules apply from the
next deployment.
Inside the running app, noclick.Project.current() is the app itself, signed
in by its deployment (no key in the app's code), and the whole client acts as a
key of the app's user: app.database("DB") reaches what it's bound to,
noclick.Agent.from_name(..., create_if_missing=True) makes what it needs,
app.ai and app.agent("SUPPORT") think and talk as the app, and
app.verify_request(...) checks a scheduled request. What the app spends
counts toward its budget_usd_per_month ($10 unless set) and its user's cap.
class ProjectAn app: a repo deployed to a URL. Project.from_name("booking", repo=repo, create_if_missing=True) is lazy; app.deploy() builds and
ships it.
- Like every live object, it also has hydrate(), refresh() and raw.
| id | str | |
| name | str | |
| url | str | Its production URL ( |
| hostname | str | Its own name under NoClick's user-sites domain ( |
| repo_id | str | None | None for an app NoClick publishes ( |
| published | bool | NoClick publishes its files itself (an automation's interface): it has no repo, and deploys when it's published again from there. |
| workflow | dict[str, Any] | None |
|
| runtime | str |
|
| framework | str | None | On |
| production_branch | str | The branch whose deploys go live (the repo's default). |
| domain_names | list[str] | The live hostnames of the domains serving it, oldest first; |
| deploy_on_push | str | What a push to its repo deploys by itself: |
| preview_resources | str |
|
| budget_usd_per_month | float | None | The most it may spend in a calendar month, in US dollars: $10 unless its maker set another. An app with code always has one; None only for an app NoClick publishes. |
| check_paths | list[str] | The paths a |
| region | str | None | The region its server code is placed near ( |
| user_id | str | None | |
| created_at | Any | |
| production | Deployment | None | The deployment serving |
| repo | Repo | None | The repo it deploys from (None for an app NoClick publishes). |
| secrets | ProjectSecrets | Its secrets: |
| settings | ProjectSettings | Its plain settings: |
| ProjectEmail | Transactional email from the app. | |
| payments | ProjectPayments | Payments into an account the owner claims. |
| sign_in | ProjectSignIn | Sign-in for the app's own users. |
| ai | ProjectAI |
|
createclassmethodasync
noclick.Project.create(
name, *, repo=None, runtime=None, framework=None, production_branch=None,
budget_usd_per_month=None, region=None, deploy_on_push=None, user=..., client=None
) -> ProjectCreate a project called name (unique per user; a duplicate
raises noclick.Conflict with existing_id) that deploys repo
(a noclick.Repo or its id). Without repo a new, empty repo of
the same name is created for it. runtime is auto unless given:
each deployment runs where its commit says, on the edge (a wrangler
config naming its entry, a JavaScript framework, or only files: server
code in isolates, static files served from everywhere) or in a
container that scales to zero (a Dockerfile, or a server in another
language: Flask, Django, Go, or a Node server with a start script),
and the deployment says which and why. edge or container pins
it there whatever the commit has. framework is detected from the
repo at each build when omitted: on edge nextjs, remix,
astro, vite or static; on container docker (the
repo's own Dockerfile), else python or node.
production_branch is the repo's default branch.
deploy_on_push (off unless given) makes a push deploy by itself:
production deploys a push to the production branch, all a push
to any other branch too, as a preview.
budget_usd_per_month caps what the app may spend in a calendar month:
its hosting and builds, its calls to what it's bound to, its email, its AI
and the agent turns it starts, all of which count toward its user's cap too.
Every app gets one: $10 unless you pass another (an agent making a site sets
one that fits it). At it the app's paid calls stop and its pages keep serving;
at twice it its pages pause.
region (noclick.regions()) places its server code near that region,
for good: put it with the database it binds (region=db.region), so its
queries stay short. Without one, your account's default region, else
us-east. Its static files are served from everywhere. It says where the
code runs, not where data stays: that's the database's and bucket's region.
NoneNoneNoneNoneNoneNoneNoneWho the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The new project, loaded. Nothing is deployed until deploy().
Deploy code you already committed:
repo = noclick.Repo.from_name("booking")
app = noclick.Project.create("booking", repo=repo)
print(app.url) # https://booking.noclick.siteInside as_user the app is that user's, and what it spends counts toward their cap
and its own budget:
with noclick.as_user("acme"):
app = noclick.Project.create("status-page", framework="astro", budget_usd_per_month=10)from_nameclassmethod
noclick.Project.from_name(
name, *, repo=None, create_if_missing=False, runtime=None, framework=None,
production_branch=None, budget_usd_per_month=..., region=None, deploy_on_push=None,
user=..., client=None
) -> ProjectA lazy reference to the project called name in the current (or
given) user. With create_if_missing=True your code is the
definition: it's created on first use with the fields Project.create
takes, and an existing project whose repo, runtime, framework,
production branch, budget or deploy_on_push differ is updated to
match. A field you leave out is never changed; an app with code always
has a budget, so budget_usd_per_month=None raises
noclick.InvalidRequest. region is fixed
once it's created: an existing app in another region raises
noclick.Conflict naming its region.
NoneFalseNoneNoneNoneNoneNoneWho the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)A project that loads (or is created, or brought in step with your definition) the first time it's used. Nothing is sent yet.
Refer to an app by name:
app = noclick.Project.from_name("booking")
print(app.url, app.production)Or make your code the definition. The same lines set it up the first time and deploy it every time after:
repo = noclick.Repo.from_name("booking", create_if_missing=True)
app = noclick.Project.from_name("booking", repo=repo, budget_usd_per_month=25, create_if_missing=True)
app.deploy().wait()currentclassmethod
noclick.Project.current(*, client=None) -> ProjectThe app this code runs in, from inside a deployment. NoClick puts the
deployment's own credential in its environment (NOCLICK_DEPLOYMENT_TOKEN), so
the app's code holds no key, and the whole client (noclick.Agent,
noclick.Database, noclick.AI, ...) acts as a key of the app's user, attributed
to the app. Outside a deployment it raises noclick.Unauthenticated; pass
client= to act with a key of your own instead. Python runs in an app on the
container runtime; an edge app's code uses the TypeScript client's
Project.current().
What its code makes (an agent, a database, a sandbox) or buys (a phone number, a domain)
is the app's own. Its user's other resources it reads and uses but never changes or
deletes (on one bound to the app, it may clear its data), and it never reaches another
app, another user, or what another app's code made. It never mints or revokes keys,
sets a cap or budget (its own included), changes policy, answers approvals, accepts
terms for a person, or reads secrets' values or raw tokens: those raise
noclick.PermissionDenied. It talks to agents in threads of its own (agent), and
never to one holding the noclick tool. The app's code decides who may make it do
what: policy doesn't judge its calls (an agent it messages still runs under its policy).
What the app does is billed to your account, attributed to the app and its user, and
counts toward the app's budget_usd_per_month and its user's cap: what its code
spends, what it buys (each renewal in the month it renews) and what bills after its
request (its numbers' calls and texts, its automations' runs, its MCP servers, channels,
machines and cards). A purchase must fit what's left of the budget, and one in a
person's name raises noclick.ApprovalRequired until they approve it. Once the budget
or the cap is used up the app keeps serving and reading its data, but its paid calls
(writes, email, app.ai, agent turns) raise noclick.SpendCapExceeded naming the
app; catch it and serve the page without them.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The app, loaded on first use. Nothing is sent yet.
In a request handler of a deployed app, read what the app is bound to by name:
app = noclick.Project.current()
db = app.database("DB")
rows = db.query("select id, starts_at from bookings where day = ?", ["2026-10-02"])
app.email.send(to="ana@example.com", subject="Your booking", text="See you Friday at 3pm.")Make what it needs as it goes, which is the app's own from then on:
noclick.Project.current()
helper = noclick.Agent.from_name("helper", instructions="Answer in French.", tools=["search"],
create_if_missing=True)
print(helper.message("Bonjour !", conversation="visitor-91c2").text)from_idclassmethod
noclick.Project.from_id(project_id, *, client=None, user=...) -> ProjectA lazy reference to a project by id.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
A project that loads the first time you read a field. Nothing is sent yet.
app = noclick.Project.from_id("2b7e9c4a-6f1d-4a3b-8e5c-0d9f7a1b3c52")
op = app.deploy()getclassmethodasync
noclick.Project.get(project_id, *, user=..., client=None) -> ProjectLoad a project by id now; an unknown id raises noclick.NotFound.
Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The project, loaded, with its production deployment.
app = noclick.Project.get("2b7e9c4a-6f1d-4a3b-8e5c-0d9f7a1b3c52")
print(app.name, app.url, app.framework)listclassmethodasync
noclick.Project.list(
*, limit=None, user_filter=None, user=..., client=None
) -> list[Project]The current user's projects (the account's own outside as_user, or those user_filter covers).
Return at most this many.
None)Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Every matching project, newest first, fetched page by page, each with its production deployment.
for app in noclick.Project.list(user_filter=noclick.ALL_USERS):
live = app.production
print(app.name, app.url, live.status if live else "not deployed")list_pageclassmethodasync
noclick.Project.list_page(
*, cursor=None, limit=None, user_filter=None, user=..., client=None
) -> PageOne page of projects; pass next_cursor back as cursor.
The next_cursor of the page before; omit it for the first page.
None)Return at most this many.
None)Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)A page whose data holds the projects and whose next_cursor is None on the last page.
page = noclick.Project.list_page(limit=50)
while True:
for app in page.data:
print(app.name, app.url)
if page.next_cursor is None:
break
page = noclick.Project.list_page(limit=50, cursor=page.next_cursor)analytics_summaryclassmethodasync
noclick.Project.analytics_summary(
*, period='7d', tz=None, user_filter=None, user=..., client=None
) -> AnalyticsSummaryEvery app's requests over the last period (24h, 7d or 30d) and how many failed, in one
read however many apps there are: each production site's, over the same window analytics reads, its days
starting in tz (UTC when omitted). Which apps: the current user's, or those user_filter covers, as
list. Requests, not visitors.
'7d'NoneWhich users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Its AnalyticsSummary: one line per app, newest first.
Each app's week, busiest first:
week = noclick.Project.analytics_summary(tz="Europe/Paris")
for line in sorted(week.apps, key=lambda a: a.requests, reverse=True):
print(line.project_id, line.requests, line.errors)updateasync
app.update(
*, repo=..., runtime=..., framework=..., production_branch=...,
budget_usd_per_month=..., deploy_on_push=..., preview_resources=...,
check_paths=...
) -> ProjectChange the repo it deploys (a noclick.Repo or its id), its runtime
(auto, edge or container), its framework, its production_branch, its
budget_usd_per_month, what a push deploys (deploy_on_push: off,
production or all), the paths a promote checks first (check_paths, up to 10,
each starting with /; [] stops checking) or whether previews get their own
resources (preview_resources: branch or shared). A new repo, runtime, framework
or branch applies from the next deployment (what's live keeps serving where it runs until
then), the rest at once. An app with code always has a budget: budget_usd_per_month=None
raises noclick.InvalidRequest (only an app NoClick publishes may have none). Its user's
cap still applies either way. Pinning a runtime the app's framework doesn't run on raises
noclick.InvalidRequest: change the framework with it.
This app, updated. A field you leave out is unchanged.
Give an app a budget of its own, so it can't spend all of its user's cap:
app = noclick.Project.from_name("booking", user="user_42")
app.update(budget_usd_per_month=25)Lift it for the rest of the month after a busy launch:
app = noclick.Project.from_name("booking", user="user_42")
if app.usage().limited_by == "project":
app.update(budget_usd_per_month=50)Deploy every push to the production branch from now on:
app = noclick.Project.from_name("booking")
app.update(deploy_on_push="production")Promote only releases whose home and booking pages load cleanly:
app = noclick.Project.from_name("booking")
app.update(check_paths=["/", "/book"])Give an app made before branched previews its own copies per branch, so a preview's writes never reach production's data:
app = noclick.Project.from_name("booking")
app.update(preview_resources="branch")Let an app made before auto run each commit where it says (its next deploy of a
Dockerfile runs in a container):
app = noclick.Project.from_name("booking")
app.update(runtime="auto", framework=None)
deployment = app.deploy().wait()
print(deployment.runtime, deployment.runtime_reason)deployasync
app.deploy(*, branch=None, commit=None) -> OperationBuild and deploy branch (the production branch when omitted) at
its head, or at commit. A deploy of the production branch goes live
at url once it's ready; any other branch is a preview with a URL of
its own.
NoneNoneAn operation whose .wait() gives the Deployment, ready. A failed build raises
noclick.OperationFailed, whose deployment_id names the deployment to read the build log of.
Deploy production and wait for it:
app = noclick.Project.from_name("booking")
deployment = app.deploy().wait()
print(deployment.url, deployment.commit)Deploy a branch as a preview, and read the build log when it fails:
app = noclick.Project.from_name("booking")
try:
preview = app.deploy(branch="new-form").wait()
print(preview.url) # https://new-form--booking.noclick.site
except noclick.OperationFailed as failed:
for line in noclick.Deployment.from_id(failed.deployment_id).logs(kind="build"):
print(line.message)deploymentsasync
app.deployments(*, branch=None, limit=20) -> list[Deployment]Its deployments, newest first, of every branch or of branch.
NoneReturn at most this many.
20)Up to limit deployments (20 by default; None for all), fetched page by page.
app = noclick.Project.from_name("booking")
for deployment in app.deployments(limit=10):
print(deployment.branch, deployment.status, deployment.production, deployment.url)rollbackasync
app.rollback(*, to) -> OperationServe an earlier deployment (a Deployment or its id, still
ready) at url again, without rebuilding: one routing change, live
within a minute. The same as to.promote(). It covers the app's last 10
production deployments; an older one is built again from its commit.
An operation whose .wait() gives that deployment (or, past the window, the one built
from its commit), now production.
Go back to the production deployment before the current one:
app = noclick.Project.from_name("booking")
previous = [d for d in app.deployments(branch=app.production_branch) if d.status == "ready" and not d.production][0]
app.rollback(to=previous).wait()bindasync
app.bind(name, resource, *, kind=None) -> BindingGive the app resource under name: a noclick.Database,
Bucket, Connection, Dict, Queue, Secret or Agent, or its id
with kind (database, bucket, connection, dict, queue,
secret or agent). The running app reads it
from its environment by that name. Binding a name again replaces what it
pointed at; it takes effect at the next deployment. An agent is the app's
user's own or one of your account's, and the app talks to it as itself (see
agent).
NoneThe binding, naming the resource.
Give the app its database, its files and the owner's CRM:
app = noclick.Project.from_name("booking")
app.bind("DB", noclick.Database.from_name("booking-db", create_if_missing=True))
app.bind("FILES", noclick.Bucket.from_name("uploads", create_if_missing=True))
app.bind("CRM", noclick.Connection.from_name("hubspot"))
app.deploy()Give it an agent to answer its visitors:
app = noclick.Project.from_name("booking")
app.bind("SUPPORT", noclick.Agent.from_name("support"))
app.deploy()With an id, say what it is:
app = noclick.Project.from_name("booking")
app.bind("DB", "5c2e8a1f-3b7d-4e9a-a6c4-8d1f0e2b3a79", kind="database")bindingsasync
app.bindings() -> list[Binding]What the app is given, by name.
Each binding with the resource it names.
app = noclick.Project.from_name("booking")
for binding in app.bindings():
print(binding.name, binding.kind, binding.resource_name) # DB database booking-dbunbindasync
app.unbind(name) -> NoneTake name away from the app at the next deployment. The resource itself is kept.
app = noclick.Project.from_name("booking")
app.unbind("CRM")databaseasync
app.database(name) -> DatabaseThe database bound to the app as name, from the app's own code
(Project.current()) or yours.
The noclick.Database; a name bound to something else raises noclick.InvalidRequest,
an unbound one noclick.NotFound.
app = noclick.Project.current()
bookings = app.database("DB").query("select count(*) as n from bookings")
print(bookings.rows[0]["n"])bucketasync
app.bucket(name) -> BucketThe bucket bound to the app as name.
The noclick.Bucket.
app = noclick.Project.current()
files = app.bucket("FILES")
files.put("invoices/1042.pdf", b"%PDF-...")dictasync
app.dict(name) -> DictThe dict bound to the app as name.
The noclick.Dict.
app = noclick.Project.current()
sessions = app.dict("SESSIONS")
sessions.put("sess_91c2", {"user": "ana"}, ttl_s=3600)queueasync
app.queue(name) -> QueueThe queue bound to the app as name.
The noclick.Queue.
app = noclick.Project.current()
app.queue("JOBS").put({"booking": 1042, "send": "reminder"})connectionasync
app.connection(name) -> ConnectionThe connection bound to the app as name: the app runs its operations as the
account that connected it.
app = noclick.Project.current()
crm = app.connection("CRM")
crm.run("create_contact", {"email": "ana@example.com"})agentasync
app.agent(name) -> AgentThe agent bound to the app as name. From the app's own code it acts as the app:
message, send and ask start turns in the app's own conversations, and
history and conversations read only those. A key the app passes (its visitor's
id, say) always names a thread of the app's: visitor-91c2 from the app is
app:<app id>:visitor-91c2 to your own code, which reads those threads, and no other
app reaches them. The agent's policy still judges its tool calls, and its turns are
billed like any turn, attributed to the app: to the agent's budget, the app's and its
user's cap. Anything else (update, delete, its triggers, memory, wake-ups or
children) raises noclick.PermissionDenied from the app, unless its code made the
agent. A preview's code talks to it in threads of the preview's own
(app-preview:<app id>:<key>), its tools answering from a simulated world; no app's
code talks to an agent that holds the noclick tool.
The noclick.Agent.
Answer a visitor in a thread of their own, and degrade once the money runs out:
app = noclick.Project.current()
support = app.agent("SUPPORT")
try:
reply = support.message("Can I move my booking to Saturday?", conversation="visitor-91c2")
print(reply.text)
except (noclick.SpendCapExceeded, noclick.PaymentRequired):
print("Chat is resting for now. Email help@acmebooking.com and we'll answer today.")verify_request
app.verify_request(body, headers, *, tolerance_s=300) -> NoneCheck that a request to the app came from one of its schedules: body is the raw
body exactly as it arrived, headers its headers. Every scheduled request carries a
NoClick-Signature header (t=<unix seconds>,v1=<hex>, HMAC-SHA256 of
"<t>.<body>") keyed with the app's signing secret, which NoClick sets in the
deployment's environment as NOCLICK_SIGNING_SECRET. It sends nothing.
Raises noclick.WebhookSignatureError when the header is missing or malformed, the
timestamp is more than tolerance_s from now, or the signature doesn't match; answer
such a request 401.
300In a Flask route a schedule calls, in an app on the container runtime:
from flask import Flask, request
server = Flask(__name__)
@server.post("/jobs/reminders")
def reminders():
app = noclick.Project.current()
try:
app.verify_request(request.get_data(), request.headers)
except noclick.WebhookSignatureError:
return "", 401
return "", 204scheduleasync
app.schedule(cron, *, path, name=None) -> SchedulePOST path on the app on every tick of cron (a
noclick.Cron). A schedule is keyed by name (the path when
omitted), so calling this again with the same one updates it rather
than adding another. Each request carries a signed NoClick-Signature
header, so the app can refuse anyone else.
The schedule, active, with its next_run.
Send reminders at 9am New York time on weekdays:
app = noclick.Project.from_name("booking")
reminders = app.schedule(noclick.Cron("0 9 * * 1-5", tz="America/New_York"), path="/jobs/reminders")
print(reminders.next_run)Two schedules on one path need names:
app = noclick.Project.from_name("booking")
app.schedule(noclick.Cron("0 * * * *"), path="/jobs/sync", name="hourly-sync")
app.schedule(noclick.Cron("0 3 * * 0"), path="/jobs/sync", name="weekly-full-sync")schedulesasync
app.schedules() -> list[Schedule]Its schedules.
Each schedule with its status, next_run and how its last_run went.
app = noclick.Project.from_name("booking")
for schedule in app.schedules():
print(schedule.cron, schedule.tz, schedule.path, schedule.status, schedule.last_status)add_domainasync
app.add_domain(name) -> DomainServe the app at the domain name too. A domain you bought through
NoClick is pointed at it at once, at its name and www; one owned elsewhere
is connected and waits in pending_dns until its records are set (records
lists them). Each name is live once its certificate is issued.
A subdomain of a domain your account holds (ana.acmebooking.com under the account's
acmebooking.com) can serve any project, a user's included, so each user's app gets an
address of yours; only a key acting for the whole account (not a user session or a key
pinned to a user) may hand one out. Under a bought domain the record is written for you;
under a connected one connected with wildcard=True it needs no record of its own and
is live within minutes, and otherwise records lists the one to add.
The noclick.Domain, with its status.
Serve the app at a domain you own:
app = noclick.Project.from_name("booking")
domain = app.add_domain("book.acme.com")
print(domain.status) # pending_dns until the records are set
for record in domain.records:
print(record.type, record.name, record.value)Give a user's app a subdomain of your account's domain:
ana_app = noclick.Project.from_name("store", user="user_42")
domain = ana_app.add_domain("ana.acmebooking.com")
print(domain.status) # active: the account bought acmebooking.com through NoClickdomainsasync
app.domains() -> list[Domain]The domains that serve the app.
Each noclick.Domain with its status.
app = noclick.Project.from_name("booking")
for domain in app.domains():
print(domain.name, domain.status)remove_domainasync
app.remove_domain(name) -> NoneStop serving the app at name (a bare domain's www too). The domain itself is
kept: a bought one stays yours, and one connected for the app stays connected until you
delete it. A name that doesn't serve the app raises noclick.NotFound.
app = noclick.Project.from_name("booking")
app.remove_domain("book.acme.com")logsasync
app.logs(*, since=None, level=None, deployment=None, limit=100) -> list[LogLine]What the app printed while serving, oldest first: the last limit
lines (at most 1,000), or with since (a timezone-aware
datetime) the first limit after it. level keeps warn or
error lines only; deployment one deployment's. Lines are kept 7 days.
NoneNoneNoneReturn at most this many.
100)The lines. Pass the last one's at as since to read on from there.
The errors of the last hour:
from datetime import datetime, timedelta, timezone
app = noclick.Project.from_name("booking")
an_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
for line in app.logs(since=an_hour_ago, level="error"):
print(line.at, line.message)Follow the log, a page at a time:
app = noclick.Project.from_name("booking")
lines = app.logs(limit=50)
while lines:
for line in lines:
print(line.message)
lines = app.logs(since=lines[-1].at, limit=50)analyticsasync
app.analytics(*, period='7d', environment='production', tz=None) -> ProjectAnalyticsHow the app's traffic went over the last period (24h, 7d or 30d): its requests each hour
(24h) or day, starting in tz (an IANA time zone; UTC when omitted), how many failed, the totals
against the same length of time before, by status, how long answers took to begin and the busiest paths.
environment: its production site (production), its previews (preview) or both (all).
It counts every request the app's sites answered, its static files and API calls included, people's and bots' alike: requests, not visitors.
'7d''production'NoneIts ProjectAnalytics, read from NoClick's record of each request.
How the site is doing this week, against last week:
app = noclick.Project.from_name("booking")
week = app.analytics(tz="Europe/Paris")
print(week.requests, week.previous_requests, week.error_rate, week.p95_ms)
for day in week.series:
print(day.at.date(), day.requests, day.errors)
for page in week.top_paths:
print(page.path, page.requests)The last 24 hours, hour by hour:
for hour in app.analytics(period="24h").series:
print(hour.at, hour.requests)usageasync
app.usage() -> ProjectUsageWhat the app has spent this month, against its budget: everything it cost in
usd (hosting and builds, its calls to what it's bound to, its email, its AI and its
agent turns), split by_type, and limited_by, what refuses its paid calls now.
Requests served, compute in cpu_ms, usd and its budget_usd, what refuses its paid
calls (limited_by), and the period_start it counts from.
Show the app's spend against its budget:
app = noclick.Project.from_name("booking")
used = app.usage()
print(used.requests, used.cpu_ms, f"${used.usd:.2f}")
if used.budget_usd is not None:
print(f"${used.usd:.2f} of ${used.budget_usd:.2f}", used.by_type)Find out why its paid calls fail:
app = noclick.Project.from_name("booking")
if app.usage().limited_by == "user":
print("Its user reached their monthly cap.")exportasync
app.export() -> OperationPackage everything the app is, to run it elsewhere: its code as a
git bundle, apps/<name>.json with its settings, bindings and secret
names (never values), and each database, bucket, dict and queue it binds,
each in its own export's format. To put the code on GitHub, use the
repo's export_to_github.
An operation whose .wait() gives an noclick.ExportBundle.
app = noclick.Project.from_name("booking")
bundle = app.export().wait()
print(bundle.url)
for item in bundle.contents:
print(item.kind, item.name, item.detail)deleteasync
app.delete(*, delete_data=False) -> TrashItemDelete the app: it goes offline within seconds and to the trash,
restorable for 30 days with everything it had (its releases,
settings, secrets, bindings, schedules and domains), served again
with no build. Its name is free meanwhile. The repo and every bound
database and bucket are kept, unless delete_data=True moves the
repo to the trash too, with each database and bucket no other app
binds.
FalseDelete the app and keep its data:
app = noclick.Project.from_name("status-page", user="acme")
item = app.delete()Or delete what only it used as well, then change your mind:
item = noclick.Project.from_name("scratch").delete(delete_data=True)
item.restore()Deployment
class DeploymentOne build of a project at one commit. status goes queued,
building, then ready (serving at url), failed (error
says why) or canceled. production is True for the one serving the
project's own URL.
- Like every live object, it also has hydrate(), refresh() and raw.
| id | str | |
| project_id | str | |
| branch | str | |
| commit | str | None | The sha it was built from. |
| commit_message | str | None | |
| status | str | |
| url | str | None | Where this deployment serves (a preview URL unless it's production). |
| production | bool | |
| environment | str |
|
| started_by | str |
|
| error | str | None | Why the build failed, or why a push couldn't deploy, in one line (None unless |
| runtime | str | None | Where it runs: |
| runtime_reason | str | None | Why it runs there, in words: the app's pinned runtime, or what its commit has ( |
| created_at | Any | |
| ready_at | Any | |
| last_check | dict[str, Any] | None | Its latest check, in short: |
from_idclassmethod
noclick.Deployment.from_id(deployment_id, *, client=None, user=...) -> DeploymentA lazy reference to a deployment by id.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
A deployment that loads the first time you read a field. Nothing is sent yet.
deployment = noclick.Deployment.from_id("0d6c2f8e-4b1a-4e97-a3c5-7f8e9d0c1b2a")
for line in deployment.logs(kind="build"):
print(line.message)getclassmethodasync
noclick.Deployment.get(deployment_id, *, user=..., client=None) -> DeploymentLoad a deployment by id now; an unknown id raises noclick.NotFound.
Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The deployment, loaded.
deployment = noclick.Deployment.get("0d6c2f8e-4b1a-4e97-a3c5-7f8e9d0c1b2a")
print(deployment.status, deployment.url, deployment.error)logsasync
deployment.logs(*, kind='build', limit=None) -> list[LogLine]Its build output (kind="build") or what it printed while
serving (kind="runtime"), oldest first.
'build'Return at most this many.
None)The lines, or the last limit of them. A build keeps every line; runtime lines are kept 7 days.
Find out why a build failed:
deployment = noclick.Deployment.get("0d6c2f8e-4b1a-4e97-a3c5-7f8e9d0c1b2a")
if deployment.status == "failed":
for line in deployment.logs(kind="build"):
print(line.at, line.level, line.message)Read the last few lines a preview printed:
preview = noclick.Deployment.from_id("0d6c2f8e-4b1a-4e97-a3c5-7f8e9d0c1b2a")
tail = preview.logs(kind="runtime", limit=20)cancelasync
deployment.cancel() -> DeploymentStop a deployment that's still queued or building; one that
already finished is left as it is.
This deployment, canceled unless it had already finished.
deployment = noclick.Deployment.get("0d6c2f8e-4b1a-4e97-a3c5-7f8e9d0c1b2a")
deployment.cancel()
print(deployment.status) # canceledcheckasync
deployment.check(paths=None) -> OperationLoad paths of this ready deployment where it serves (its preview URL, or the app's URL when
it's production) in a browser, one after another: each one's HTTP status, load time, console errors and
a screenshot, kept as a file the chat shows. Without paths, the app's check_paths (else its home
page). A deployment no URL serves now can't be checked (noclick.Conflict). The browser is billed at
its provider's price, by the second.
NoneAn operation whose .wait() gives a DeploymentCheck; passed is False when a path answered
400 or more, didn't load, or a script on it threw.
Check the pages you changed before you say they work:
preview = noclick.Project.from_name("booking").deploy(branch="new-form").wait()
checked = preview.check(["/", "/book"]).wait()
for page in checked.paths:
print(page.path, page.status, page.problem, page.screenshot["url"])
for error in page.console_errors:
print(" ", error["kind"], error["message"])requestsasync
deployment.requests(*, since=None) -> RequestOutcomesHow the requests this deployment served went since since (a timezone-aware datetime; the last 24
hours when omitted, at most 7 days back): how many, by status, the error rate and the slowest routes.
NoneIts RequestOutcomes, read from NoClick's record of each request it answered, files included.
See whether a release is failing in production:
app = noclick.Project.from_name("booking")
outcomes = app.production.requests()
print(outcomes.requests, outcomes.error_rate, outcomes.by_status)
for route in outcomes.slowest_routes:
print(route.method, route.path, route.p95_ms)promoteasync
deployment.promote(*, force=False) -> OperationMake this ready deployment the project's production one: its
URL starts serving this build within a minute. Promoting an older
production deployment is a rollback; a preview is released again with
production's secrets, without a build.
An app with check_paths checks the deployment first (check())
and doesn't promote one that fails: the operation fails with the check
in its error, and production keeps serving. force=True promotes it
without the check. Before production switches, the databases the app
binds are bookmarked: undoing the promote (Operation.undo()) puts
them back as they were, with the release before.
Instant rollback covers the app's last 10 production deployments, and
each branch's newest preview for 30 days. One older than that was retired:
promoting it builds its commit again, and the operation (project.deploy)
gives the new deployment.
FalseAn operation whose .wait() gives this deployment (or, past the window, the one built
from its commit), now production.
Put a preview live once you've checked it:
app = noclick.Project.from_name("booking")
preview = app.deploy(branch="new-form").wait()
print(preview.url) # check it here first
preview.promote().wait()Take it back, data included, if it goes wrong:
promoted = preview.promote()
promoted.wait()
promoted.undo()Schedule
class ScheduleA project's cron job: NoClick POSTs path on the app on every tick
of cron (read in tz). When 5 runs in a row fail the same way over
at least 30 minutes it pauses itself (status paused,
paused_reason says why) until you resume() it.
- Like every live object, it also has hydrate(), refresh() and raw.
| id | str | |
| project_id | str | |
| name | str | What keys it within the project (the path unless you named it). |
| cron | str | The five-field cron expression. |
| tz | str | |
| path | str | |
| status | str |
|
| paused_reason | str | None | |
| next_run | Any | |
| last_run | Any | |
| last_status | str | None | How the last run went: |
| last_error | str | None | Why the last run failed, in one line ( |
| created_at | Any |
from_idclassmethod
noclick.Schedule.from_id(schedule_id, *, client=None, user=...) -> ScheduleA lazy reference to a schedule by id.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
A schedule that loads the first time you read a field. Nothing is sent yet.
schedule = noclick.Schedule.from_id("6a1f3c9e-2d4b-4e8a-b7c0-5d9e8f1a2b3c")
schedule.pause()getclassmethodasync
noclick.Schedule.get(schedule_id, *, user=..., client=None) -> ScheduleLoad a schedule by id now; an unknown id raises noclick.NotFound.
Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The schedule, loaded.
schedule = noclick.Schedule.get("6a1f3c9e-2d4b-4e8a-b7c0-5d9e8f1a2b3c")
print(schedule.cron, schedule.tz, schedule.next_run, schedule.last_status)pauseasync
schedule.pause() -> ScheduleStop firing until resume().
This schedule, paused.
schedule = noclick.Schedule.get("6a1f3c9e-2d4b-4e8a-b7c0-5d9e8f1a2b3c")
schedule.pause()resumeasync
schedule.resume() -> ScheduleFire again from the next tick, clearing paused_reason.
This schedule, active, with its next_run.
Fix what made it fail, then resume it:
app = noclick.Project.from_name("booking")
for schedule in app.schedules():
if schedule.status == "paused":
print(schedule.path, schedule.paused_reason)
schedule.resume()run_nowasync
schedule.run_now() -> ScheduleCall its path once now, outside the cron, and wait for the answer (at most 30 seconds). A paused schedule runs too, and stays paused.
This schedule, with last_run and last_status from that call.
schedule = noclick.Schedule.get("6a1f3c9e-2d4b-4e8a-b7c0-5d9e8f1a2b3c")
schedule.run_now()
print(schedule.last_status) # succeededdeleteasync
schedule.delete() -> NoneDelete the schedule; it stops firing.
schedule = noclick.Schedule.from_id("6a1f3c9e-2d4b-4e8a-b7c0-5d9e8f1a2b3c")
schedule.delete()ProjectSecrets
class ProjectSecretsapp.secrets: values the running app reads from its environment by
name, set for production deployments, previews or both. Values are
write-only; nothing reads one back. An app's environment holds 128 values
in all, NoClick's own and each bound secret's included.
setasync
app.secrets.set(name, value, *, environment='all') -> NoneSet the secret name (letters, digits and _, starting with a
letter) to value (at most 5 KB), replacing any value it had, for
environment: production (deploys of the production branch, and
what's promoted), preview (every other branch) or all. It
reaches the app at the next deployment. Keep a larger value in a bound
noclick.Secret or bucket the app reads.
'all'The same key everywhere:
app = noclick.Project.from_name("booking")
app.secrets.set("MAPS_API_KEY", "...")
app.deploy()A live key in production and a test key in previews:
app = noclick.Project.from_name("booking")
app.secrets.set("STRIPE_KEY", "sk_live_...", environment="production")
app.secrets.set("STRIPE_KEY", "sk_test_...", environment="preview")listasync
app.secrets.list(*, environment='all') -> dict[str, datetime]The project's secrets by name, never their values: those set for
environment (production, preview or all).
'all'Each secret's name mapped to when its value was last set.
app = noclick.Project.from_name("booking")
for name, updated_at in app.secrets.list(environment="preview").items():
print(name, updated_at)deleteasync
app.secrets.delete(name, *, environment='all') -> NoneDelete the secret name from environment (all by default);
the app stops seeing it at the next deployment. A name that isn't set
there raises noclick.NotFound.
'all'app = noclick.Project.from_name("booking")
app.secrets.delete("MAPS_API_KEY")versionsasync
app.secrets.versions(name, *, environment='all') -> list[Version]What name held before each change or deletion of the last 30 days, newest first, in
environment (production, preview or all), never the values.
'all'Each earlier version, with its environment.
app = noclick.Project.from_name("booking")
for version in app.secrets.versions("STRIPE_KEY", environment="production"):
print(version.op, version.replaced_at)restoreasync
app.secrets.restore(name, version) -> NoneSet name back to version's value in its environment (deleted since, it's back). It reaches the
app at the next deployment.
Undo deleting a secret:
app = noclick.Project.from_name("booking")
app.secrets.delete("MAPS_API_KEY")
app.secrets.restore("MAPS_API_KEY", app.secrets.versions("MAPS_API_KEY")[0])ProjectSettings
class ProjectSettingsapp.settings: plain values the running app reads from its environment by
name, such as a feature flag or a public URL. Unlike app.secrets they read back,
and they show in the console; put anything sensitive in app.secrets.
setasync
project_settings.set(name, value) -> NoneSet name (letters, digits and _, starting with a letter; not a secret's
name) to value (at most 4 KB). It reaches the app at the next deployment.
app = noclick.Project.from_name("booking")
app.settings.set("PUBLIC_BASE_URL", "https://book.acme.com")
app.deploy()listasync
project_settings.list() -> dict[str, str]The project's settings and their values.
Each setting's name mapped to its value.
app = noclick.Project.from_name("booking")
for name, value in app.settings.list().items():
print(name, value)deleteasync
project_settings.delete(name) -> NoneDelete the setting name; the app stops seeing it at the next deployment.
A name that isn't set raises noclick.NotFound.
app = noclick.Project.from_name("booking")
app.settings.delete("PUBLIC_BASE_URL")versionsasync
project_settings.versions(name) -> list[Version]What name held before each change or deletion of the last 30 days, newest first, with each
value.
Each earlier version.
app = noclick.Project.from_name("booking")
print([v.value for v in app.settings.versions("PUBLIC_BASE_URL")])restoreasync
project_settings.restore(name, version) -> strSet name back to version's value (deleted since, it's back). It reaches the app at the next
deployment.
The value it holds again.
app = noclick.Project.from_name("booking")
app.settings.restore("PUBLIC_BASE_URL", app.settings.versions("PUBLIC_BASE_URL")[0])ProjectAI
class ProjectAIapp.ai: noclick.AI acting as the app, from the app's own code
(Project.current()). Every call is billed to your account and attributed to the app
and its user: it counts toward the app's budget_usd_per_month and its user's cap, and
raises noclick.SpendCapExceeded once either is used up (noclick.PaymentRequired
once your account's balance is). Only the app acts as the app: on a project reached with
your own key, each call raises noclick.InvalidRequest (use noclick.AI there,
inside as_user for the app's user).
| base_url | str | The OpenAI-compatible endpoint, for an OpenAI client in the app's code; pair it
with |
chatasync
app.ai.chat(
messages, *, model=None, max_tokens=None, temperature=None, response_format=None,
tools=None, conversation=None
) -> ChatReplynoclick.AI.chat as the app, with the same arguments. model="agent:<name>"
reaches only an agent bound to the app, in the app's own conversations.
NoneNoneNoneNoneNoneNoneThe answer: its text, the message to append, and what it cost.
Summarize a review in a request handler:
app = noclick.Project.current()
reply = app.ai.chat("Summarize in one line: the class was great but the room was cold.")
print(reply.text)Serve the page without the summary once the app's budget is used up:
app = noclick.Project.current()
try:
summary = app.ai.chat("Summarize in one line: the class was great but the room was cold.").text
except (noclick.SpendCapExceeded, noclick.PaymentRequired):
summary = Noneembedasync
app.ai.embed(texts, *, model=None, dimensions=None) -> Embeddingsnoclick.AI.embed as the app.
NoneNoneOne vector per text, in order, with their length and what the call cost.
app = noclick.Project.current()
[vector] = app.ai.embed("Yoga for beginners on Saturday mornings").vectors
classes = app.database("DB").query(
"select id, title from classes order by embedding <=> cast(? as halfvec) limit 5", [vector])imageasync
app.ai.image(prompt, *, model=None, size=None) -> GeneratedImagenoclick.AI.image as the app.
NoneNoneThe image: a signed url that lasts 24 hours, its size and what it cost.
app = noclick.Project.current()
image = app.ai.image("A sunlit yoga studio, watercolor", size="1024x1024")
print(image.url, image.cost_usd)transcribeasync
app.ai.transcribe(audio, *, model=None, language=None, speakers=False) -> Transcriptnoclick.AI.transcribe as the app: an https URL to a recording, or its bytes.
NoneNoneFalseThe transcript: its text, timed segments, the language and how long the audio was.
app = noclick.Project.current()
words = app.ai.transcribe("https://booking.noclick.site/uploads/voicemail-1042.m4a", language="en")
print(words.text)modelsasync
app.ai.models(*, kind=None) -> list[AIModel]The models the app can call, as noclick.AI.models lists them; kind="agent"
lists the agents bound to it.
NoneEvery matching model with its prices in US dollars, the default of each kind first.
app = noclick.Project.current()
for m in app.ai.models(kind="chat"):
print(m.id, m.input_usd_per_token, m.output_usd_per_token)headersasync
app.ai.headers() -> dict[str, str]The headers an OpenAI client in the app's code sends to base_url: the
deployment's credential as the bearer, so every call is the app's.
Authorization.
Code written for OpenAI's client works as the app:
from openai import OpenAI
app = noclick.Project.current()
gpt = OpenAI(base_url=app.ai.base_url, api_key="-", default_headers=app.ai.headers())
answer = gpt.chat.completions.create(
model="openrouter/anthropic/claude-sonnet-4.6", messages=[{"role": "user", "content": "Hi"}])Binding
class BindingA resource the running app reads by name: a database, bucket, dict,
queue, secret, connection or agent (kind), with its resource_id
and resource_name.
| name | str | |
| kind | str | |
| resource_id | str | |
| resource_name | str | None | |
| created_at | Any | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
LogLine
class LogLineOne line an app printed, or its build did: when (at), its level
(info, warn or error), the message, and the deployment it came from.
| at | Any | |
| level | str | |
| message | str | |
| deployment_id | str | None | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
ProjectUsage
class ProjectUsageWhat an app spent this month so far, against its budget: requests served,
cpu_ms of compute, everything it cost in usd, and the period_start it
counts from.
| requests | int | |
| cpu_ms | int | |
| usd | float | Everything the app spent: hosting, builds, its calls to what it's bound to, its email, its AI and its agent turns. |
| period_start | Any | |
| budget_usd | float | None | Its |
| limited_by | str | None | What refuses its paid calls now: |
| by_type | dict[str, float] |
|
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
DeploymentCheck
class DeploymentCheckWhat Deployment.check() saw where the deployment serves (url): passed when every path did,
each path's PathCheck, and what the browser cost (cost_usd, its provider's price by the second).
| deployment_id | str | |
| url | str | |
| passed | bool | |
| paths | list[PathCheck] | |
| checked_at | Any | |
| cost_usd | float | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
PathCheck
class PathCheckOne path a check loaded in a browser: where it ended (url), its HTTP status, how long it took to
load (load_ms, None when it didn't load within 30 seconds), its page title, the errors its console
showed and a screenshot. ok is False when it answered 400 or more, didn't load, or a script threw and
nothing caught it; problem says which. Other console errors are reported without failing it.
| path | str | |
| url | str | |
| ok | bool | |
| problem | str | None | |
| status | int | None | |
| load_ms | int | None | |
| title | str | None | |
| console_errors | list[dict[str, str]] | Each |
| screenshot | dict[str, Any] | None | The screenshot, an account file the chat shows: |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
RequestOutcomes
class RequestOutcomesHow the requests a deployment served between since and until went: how many, by HTTP status
(by_status: {"200": 120, "500": 2, "none": 1}, none for one never answered), the errors (an
answer of 500 or more, or none) and their error_rate, and the slowest_routes by their 95th-percentile
time. sampled: there were more than the 2,000 newest it read, or the record of a very busy app is a
sample, each request in it counted for the ones it stands for.
| requests | int | |
| errors | int | |
| error_rate | float | None | |
| by_status | dict[str, int] | |
| slowest_routes | list[RouteTiming] | |
| sampled | bool | |
| since | Any | |
| until | Any | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
RouteTiming
class RouteTimingOne route's requests: how many, how many failed (errors), and their times in milliseconds.
| method | str | |
| path | str | |
| requests | int | |
| errors | int | |
| p50_ms | float | |
| p95_ms | float | |
| max_ms | float |
ProjectAnalytics
class ProjectAnalyticsHow an app's traffic went over a period (24h, 7d or 30d, from since up to until):
the requests its sites answered and the errors among them (an answer of 500 or more, or none) with their
error_rate, by_status ({"200": 1180, "404": 21, "none": 1}), how long answers took to begin
(p50_ms, p95_ms), each interval's (hour or day, starting in tz) in series, the
busiest top_paths, and the same length of time just before since (previous_requests,
previous_errors) to compare with. Counted from NoClick's record of every request, its files and API calls
included: requests, not visitors. sampled: a very busy app's record is a sample, each request in it counted
for the ones it stands for.
| period | str | |
| environment | str | |
| interval | str | |
| tz | str | |
| since | Any | |
| until | Any | |
| requests | int | |
| errors | int | |
| error_rate | float | None | |
| by_status | dict[str, int] | |
| p50_ms | float | None | |
| p95_ms | float | None | |
| series | list[TrafficInterval] | |
| top_paths | list[PathTraffic] | |
| previous_requests | int | |
| previous_errors | int | |
| sampled | bool | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
TrafficInterval
class TrafficIntervalOne hour or day of an app's traffic: the requests from at on, and how many of them failed
(errors: an answer of 500 or more, or none).
| at | Any | |
| requests | int | |
| errors | int |
PathTraffic
class PathTrafficOne of an app's busiest paths: its requests, how many failed, and their 95th-percentile time in milliseconds.
| path | str | |
| requests | int | |
| errors | int | |
| p95_ms | float | None |
AnalyticsSummary
class AnalyticsSummaryEvery app's requests over a period (from since up to until, days starting in tz), read at once:
apps newest first, an app nobody reached at 0. of(app) is one app's. Requests, not visitors.
sampled: a very busy app's record is a sample, each request in it counted for the ones it stands for.
| period | str | |
| tz | str | |
| since | Any | |
| until | Any | |
| apps | list[AppRequests] | |
| sampled | bool | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
of
analytics_summary.of(app) -> AppRequestsOne app's line in the summary.
The AppRequests of app (a Project or its id); one the summary didn't cover reads as 0.
week = noclick.Project.analytics_summary()
app = noclick.Project.from_name("booking")
print(week.of(app).requests, week.of(app).errors)AppRequests
class AppRequestsOne app's requests to its production site over a summary's period, and how many failed.
| project_id | str | |
| requests | int | |
| errors | int |
ProjectEmail
class ProjectEmailapp.email: transactional email from the app's own address (receipts, booking
confirmations), rate-capped and charged per recipient; a high bounce or complaint rate
pauses sending. One-time sign-in codes go through send_code, on a lane of their own.
Newsletters and announcements go through broadcast, on a stream of their own, so they
never hold up a receipt.
app = noclick.Project.from_name("booking")
email = app.email # this app's noclick.ProjectEmail
email.enable(domain="acmebooking.com", from_address="bookings@acmebooking.com")
email.send(to="ana@example.com", subject="Your booking", text="See you Friday at 3pm.")enableasync
app.email.enable(
*, domain=None, from_address=None, from_name=None, postal_address=None
) -> EmailSetupTurn email on for the app, or change what you pass (the rest keeps its value).
Without a domain the app sends from its default address
(noreply@booking.noclick.site). With one (a Domain or its name), mail goes from
from_address on it, noreply@<domain> unless you pass one, and broadcasts go from
the same name on its news. subdomain, so their reputation stays apart from receipts'.
The first call answers pending while NoClick sets up the app's sending (what it
sends meanwhile goes out once that's done). A domain bought through NoClick gets its
DNS records written for it and turns active within minutes; any other is connected
for you, and records lists what to add at its DNS host (within seconds of this call).
domain may also be a subdomain of a domain your account holds
(mail.ana.acmebooking.com under the account's acmebooking.com), for any
project, a user's included; only a key acting for the whole account may choose one.
Its records are written for you under a bought domain, and listed in records under
a connected one. postal_address is the address every broadcast's footer carries,
as the law asks of bulk mail; broadcast needs one.
NoneNoneNoneNoneThe setup: active when the app can send now, pending while NoClick sets up
its sending (what it sends meanwhile goes out once that's done), or pending_dns
while the domain's records aren't seen yet.
Send from your own domain:
app = noclick.Project.from_name("booking")
setup = app.email.enable(domain="acmebooking.com", from_address="bookings@acmebooking.com", from_name="Acme Booking")
print(setup.status, setup.from_address)A domain hosted elsewhere waits for its DNS records:
app = noclick.Project.from_name("booking")
setup = app.email.enable(domain="acme.test")
for record in setup.records:
print(record.type, record.name, record.value)statusasync
app.email.status() -> EmailSetupRead how the app sends email, checking the domain's DNS records again.
The current setup; status is off if email was never enabled.
app = noclick.Project.from_name("booking")
setup = app.email.status()
if setup.status == "pending_dns":
print("Still waiting for", [record.name for record in setup.records])sendasync
app.email.send(
to, subject, *, text=None, html=None, reply_to=None, idempotency_key=None
) -> SentEmailSend one message to to (an address, or a list of up to 10 that each
receive it) with a text body, an html one, or both. Email must be
active, or pending (the message waits and goes once sending is set up):
noclick.ActionRequired while the domain's records are pending,
noclick.RateLimited past the daily limit.
NoneNoneNoneMakes a retried create safe: the same key returns the first result. Generated when omitted.
None)The message as accepted, usually queued; messages() shows whether it
was delivered.
Pass an idempotency_key so a retried request can't send twice:
app = noclick.Project.from_name("booking")
sent = app.email.send(
to="ana@example.com",
subject="Your booking is confirmed",
text="See you Friday at 3pm.",
html="<p>See you <b>Friday at 3pm</b>.</p>",
idempotency_key="booking-1042-confirmation",
)
print(sent.id, sent.status)send_codeasync
app.email.send_code(
to, code, *, purpose='sign_in', expires_in_s=None, idempotency_key=None
) -> SentEmailSend a one-time code (4-12 letters and digits) to to, in a short message NoClick
writes in the app's name: purpose sign_in or verify_email, and expires_in_s
said in it when you pass one. Codes have a daily limit of their own, apart from
daily_limit, so a new app's sign-ins never wait on its other mail; one address gets at
most 5 codes in 15 minutes (noclick.RateLimited). Each is charged as one message.
app.sign_in sends its codes this way.
'sign_in'NoneMakes a retried create safe: the same key returns the first result. Generated when omitted.
None)The message, kind code; its subject names the purpose, never the code.
app = noclick.Project.current()
app.email.send_code("ana@example.com", "482913", expires_in_s=300)messagesasync
app.email.messages(*, to=None, limit=50, environment=None) -> list[SentEmail]The messages the app sent, newest first; to keeps those sent to one address, and
environment (production or preview) those its production or its previews sent.
A preview's code reaches only the account's own people, so environment="preview" is
its outbox: each message names the recipients it kept in captured, with the text
and html it would have sent.
NoneReturn at most this many.
50)NoneUp to limit messages (50 unless you say; None reads them all), each with
its delivery status, whether it was opened and why it bounced.
app = noclick.Project.from_name("booking")
for message in app.email.messages(to="ana@example.com", limit=10):
print(message.at, message.subject, message.status, message.opened)
for kept in app.email.messages(environment="preview"):
print(kept.captured, kept.subject, kept.text)broadcastasync
app.email.broadcast(
to, subject, *, idempotency_key, text=None, html=None, reply_to=None
) -> OperationSend one message to many people who asked for it: a newsletter, an announcement.
to holds up to 50,000 addresses, and listing them says each one opted in to mail
from the app. Anyone who unsubscribed from its broadcasts (until they opt in again and you
resubscribe them), bounced or marked one as spam is skipped. It goes out on a bulk
stream of its own, under broadcast_daily_limit, from broadcast_from_address on the
domain's news. subdomain, so a campaign's complaints never pause the app's transactional
mail. It needs a domain of the app's own (noclick.ActionRequired on the default sender).
Every message carries its recipient's own List-Unsubscribe header, with one-click
unsubscribe, and a footer with an unsubscribe link and the postal_address from
enable() (noclick.ActionRequired until there is one). Unsubscribing stops
broadcasts, never receipts. It goes out at the pace mail can be sent, so a large one takes
minutes.
idempotency_key names the broadcast: sending again with the same key sends nothing
new, so a job that crashes halfway can simply run again.
Makes a retried create safe: the same key returns the first result. Generated when omitted.
NoneNoneNoneapp = noclick.Project.from_name("booking")
subscribers = [row["email"] for row in app.database("DB").query("select email from subscribers")]
result = app.email.broadcast(
subscribers, "What's new at Acme Booking",
html="<h1>Group bookings are here</h1><p>Book for up to 12 people at once.</p>",
idempotency_key="newsletter-2026-10",
).wait()
print(result.sent, len(result.skipped))resubscribeasync
app.email.resubscribe(address) -> boolLift address's unsubscribe from the app's broadcasts, once they've asked for them
again and confirmed it: a click on a link you emailed that address, as when they first
subscribed. Mail after an unsubscribe is spam unless the person opted in again, so call it
only on a confirmed opt-in. A bounce or a spam complaint isn't lifted.
True if the address was unsubscribed and now gets broadcasts again, False if it
wasn't unsubscribed.
app = noclick.Project.from_name("newsletter")
# Ana clicked the confirmation link you emailed her after she signed up again.
if app.email.resubscribe("ana@example.com"):
print("Ana gets the newsletter again")EmailSetup
class EmailSetupHow an app sends email: the sender, its domain's DNS records and whether it can send.
status is off (never enabled), pending (NoClick is setting up the app's sending,
usually within a minute; mail sent meanwhile goes out once it's set up, and pending_reason
says why when setting it up failed), pending_dns (the domain's records aren't seen yet: a
domain bought through NoClick gets them written and turns active within minutes),
active, or paused (too many bounces or spam complaints; paused_reason says which:
bounce_rate, complaint_rate, mailbox_provider_feedback or blocklisted).
Broadcasts go out apart, from the domain's news. subdomain, with a status of their own.
| status | str | |
| from_address | str | None | The address mail goes out from, like |
| from_name | str | None | The display name recipients see, like |
| domain | str | None | The sending domain; |
| records | list[DnsRecord] | What DNS must hold for mail to send and land: DKIM and a bounce subdomain (MX and SPF) for the domain and for its |
| daily_limit | int | None | How many messages the app may send a day; it grows as its mail keeps landing. |
| sent_today | int | None | How many it has sent since midnight UTC. |
| paused_reason | str | None | |
| pending_reason | str | None | Why the app's sending isn't set up yet, once setting it up failed (NoClick keeps trying); else |
| postal_address | str | None | The postal address every broadcast's footer carries ( |
| broadcast_daily_limit | int | None | How many recipients its broadcasts may reach a day, apart from |
| broadcast_status | str |
|
| broadcast_from_address | str | None | The address broadcasts go out from, on the |
| broadcast_sent_today | int | None | How many broadcast recipients it has sent to since midnight UTC. |
| broadcast_paused_reason | str | None | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
| can_send | bool | Whether |
SentEmail
class SentEmailOne message an app sent. status is queued, sent (on its way to the
recipient's server), delivered, bounced, complained (marked as spam: best-effort,
since some mailbox providers, Gmail among them, report no single complaint) or failed;
error says why a message bounced or failed. Sent to several recipients, it shows the
worst any met (a bounce, a complaint), else how far the slowest got.
A preview's code reaches only the account's own people (its owner and the members of the
owner's workspaces): every other copy is kept, never sent or charged, and named in
captured; a message whose every copy was kept is captured, with the text and
html it would have sent. messages(environment="preview") is the preview's outbox.
| id | str | |
| to | list[str] | |
| subject | str | None | |
| status | str | |
| opened | bool | None | Whether the recipient opened it, as far as can be told: privacy proxies open some mail on the reader's behalf. |
| bounced | bool | |
| error | str | None | |
| at | Any | When it was sent. |
| kind | str |
|
| environment | str | Where the code that sent it runs: |
| captured | list[str] | The recipients a preview kept rather than sent to. |
| text | str | None | What a kept message would have said; |
| html | str | None | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
Broadcast
class BroadcastOne broadcast an app sent: how many recipients it was sent to, and those it
skipped, each {to, reason} with reason unsubscribed, bounced,
complained or invalid. Each message's delivery shows in app.email.messages().
From a preview, only the account's own people get it: captured counts the copies kept.
| id | str | |
| subject | str | None | |
| sent | int | |
| skipped | list[dict[str, Any]] | |
| captured | int | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
ProjectPayments
class ProjectPaymentsapp.payments: take payments in the app without a payments account of your own.
NoClick opens a test account for the app and puts its keys in the app's secrets; the
owner claims it to take real money.
app = noclick.Project.from_name("booking")
payments = app.payments # this app's noclick.ProjectPayments
setup = payments.enable(country="US")
print(setup.mode, setup.claim_url)enableasync
app.payments.enable(
*, country=None, owner_email=None, business_name=None
) -> PaymentsSetupOpen a test payments account for the app and put its keys in the app's secrets
(for production and previews). country is where the owner's business is: required
the first time, since the account can only ever be in that country (noclick.NotAvailable,
listing the countries, for one the processor doesn't serve). owner_email is who the
processor reminds before an unclaimed test account expires (your NoClick account's email
unless you pass one); business_name prefills the claim (the app's name unless you pass one).
Calling it again returns the same account, with its webhook endpoint following the app's
URL. After test_expires_at passes unclaimed, it opens a fresh test account with new
keys and no data, and the app must deploy again; once the owner has reinstalled NoClick's
payments app on a disconnected account, it reconnects it.
NoneNoneNoneThe setup in test mode, with the claim_url to send the owner.
app = noclick.Project.from_name("booking")
setup = app.payments.enable(country="US")
print(setup.secrets) # read these in the app's code
print("Claim it before", setup.test_expires_at, setup.claim_url)statusasync
app.payments.status() -> PaymentsSetupRead where the app's payments stand.
The current setup; mode moves from test to claimed to live. processor
is there even while off, so an app's payment code can be written before enable().
app = noclick.Project.from_name("booking")
if app.payments.status().mode == "live":
app.deploy() # production picks up the live keys
processor = noclick.Project.from_name("booking").payments.status().processor # before enable()PaymentsSetup
class PaymentsSetupWhere an app's payments go. mode is off (never enabled), test (a test
account that takes test cards, waiting to be claimed), claimed (the owner claimed it and
is finishing the steps claim_step names), live (live keys are in the app's production
secrets and the account can take payments) or disconnected (the owner removed NoClick's
payments app or its keys). Money goes straight to the owner's account; NoClick takes no cut.
| mode | str | |
| claim_step | str | None | While claiming: |
| next_step | str | None | What to do now, in a sentence. |
| claim_url | str | None | Where the owner claims the account: a NoClick link that always sends them to their next step, working until |
| test_expires_at | Any | When the test account and its test data are deleted unless it's claimed (60 days after |
| dashboard_url | str | None | The owner's own dashboard at the payment processor, once claimed. |
| secrets | list[str] | The project secrets that hold the account's keys. Production gets the live keys once |
| processor | str | None | Which payment processor apps are paid through, by the name its own client library goes by, so the app's code can pick the library that reads |
| country | str | None | Where the business is (ISO 3166-1 alpha-2), fixed when the account was opened. |
| webhook_url | str | None | Where the processor delivers the account's events, signed with |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
ProjectSignIn
class ProjectSignInapp.sign_in: accounts for the app's own users. Enabling it commits sign-in code
into the app's repo, where you can read and change it, and keeps the users in the app's
bound database.
app = noclick.Project.from_name("booking")
sign_in = app.sign_in # this app's noclick.ProjectSignIn
setup = sign_in.enable(providers=["email", "google"]).wait()
print(setup.commit, setup.managed_providers)enableasync
app.sign_in.enable(
providers, *, own_credentials=None, users_table='users', database=None,
branch=None
) -> OperationAdd sign-in with providers (email, google, apple, microsoft,
github) to the app: commit the code for its framework to branch (the repo's default
branch unless you pass one), create users_table and the tables it needs in its bound
database and deploy. Calling it again with another list changes the providers.
google, apple and microsoft sign in through NoClick, with nothing to set up at
the provider: its screen names NoClick. To show your own name there instead, list google
or microsoft in own_credentials and set its client's id and secret as the app's
secrets (SignInSetup.missing_secrets names them, callback_urls where to register it);
apple signs in only through NoClick, and github only with your own client. Omitted,
each provider keeps how it signs in now: one on your own client stays there.
The app needs a bound database (noclick.ActionRequired otherwise; database names
the binding when it has several). It works on nextjs, remix and astro apps, and
on a static site as it is: a worker in front of its files answers sign-in, and its pages
include /auth.js (noclick.NotAvailable for a client-only vite app, or a site that
already runs its own worker). email sends its codes through app.email.send_code, which
is enabled with the default sender if it isn't yet.
A file the change would replace that holds edits of yours is never overwritten: the change
goes on a branch of its own (SignInSetup.branch, with a preview to review), and naming
a branch whose files hold edits raises noclick.Conflict naming them.
None'users'NoneNoneAn operation whose wait() gives the SignInSetup once the code is
committed and deployed.
Add it straight to production:
app = noclick.Project.from_name("booking")
setup = app.sign_in.enable(providers=["email", "google"]).wait()
print(setup.managed_providers) # ['google']: nothing to set upWith your own Google client, to show your name on Google's screen:
app = noclick.Project.from_name("booking")
setup = app.sign_in.enable(providers=["email", "google"], own_credentials=["google"]).wait()
print(setup.callback_urls, setup.missing_secrets)Or on a branch, to review the commit on its preview deployment first:
app = noclick.Project.from_name("booking")
op = app.sign_in.enable(providers=["email"], branch="add-sign-in")
print(op.wait().commit)upgradeasync
app.sign_in.upgrade(*, branch=None) -> OperationMove the app's sign-in code to the library version NoClick ships now (update_available),
with the same providers: the same commit, migrate and deploy as enable, and the same care
for files you edited. Security fixes come this way; NoClick tells you of one with the
project.sign_in.update_available event and never pushes it to production by itself.
NoneAn operation whose wait() gives the SignInSetup.
app = noclick.Project.from_name("booking")
if app.sign_in.status().update_available:
print(app.sign_in.upgrade(branch="sign-in-update").wait().commit)statusasync
app.sign_in.status() -> SignInSetupRead the app's sign-in: its providers, where its users are kept, and what it still needs.
The current setup; status is off if sign-in was never enabled.
app = noclick.Project.from_name("booking")
setup = app.sign_in.status()
print(setup.status, setup.providers, setup.tables, setup.library, setup.version)SignInSetup
class SignInSetupAn app's sign-in. status is off, installing (a change is being committed and
deployed), active or failed (the first change failed; error says why: a later
change that fails leaves sign-in active as it was, with error). The code lives in the
app's repo and the users in users_table of the app's own database.
| status | str | |
| providers | list[str] |
|
| managed_providers | list[str] | The providers that sign in through NoClick, with nothing to set up at the provider (its screen names NoClick). The rest of |
| users_table | str | None | |
| tables | list[str] | Every table sign-in keeps in the database: |
| database | str | None | The binding of the database the users are kept in. |
| library | str | The open-source library the code is written with, and its version, so code written against the app's sign-in uses the right calls. |
| version | str | None | |
| update_available | str | None | A newer version NoClick ships ( |
| commit | str | None | The commit that added or last changed sign-in. |
| branch | str | None | Where that commit went when it wasn't the production branch: the one you passed, or the one NoClick made because files it would change hold edits of yours ( |
| edited_files | list[str] | |
| callback_urls | dict[str, str] | Where each provider on your own client sends people back to; register it with that provider. They're on the app's own NoClick address, so adding a domain doesn't change them. |
| missing_secrets | list[str] | Project secrets a provider on your own client still needs ( |
| secrets | list[str] | The secrets NoClick writes itself and never shows: |
| error | dict[str, Any] | None | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |