Apps and deployments

Deploy a repo to a URL, with previews, bindings, secrets, schedules, logs and traffic.

An app is a repo deployed to a URL. In the API it's a Project: it builds the repo, serves it, and carries what the running code needs, from databases and buckets bound by name to secrets, schedules and domains. Every branch can deploy to a preview URL of its own; the production branch serves at the app's own URL.

Create an app

Make your code the definition: from_name with create_if_missing=True creates the app the first time it's used and finds it every time after. The same goes for the repo it deploys.

import noclick

repo = noclick.Repo.from_name("booking", create_if_missing=True)
app = noclick.Project.from_name("booking", repo=repo, create_if_missing=True)
print(app.url)   # https://booking.noclick.site

NoClick reads the repo to choose how to build it (nextjs, remix, astro, vite, or static for plain files); pass framework= to choose yourself. The production branch is the repo's default branch unless you pass production_branch=. An existing app whose repo, runtime, framework or production branch differ from what your code says is updated to match.

Where it runs

Every deployment runs where its commit says, on one of two runtimes behind the same URL. An app's runtime is auto unless you pass one, and NoClick reads each commit it deploys and follows it literally, never rewriting code to fit. The first row that matches decides, so what the repo declares (a wrangler config, a Dockerfile) wins over what NoClick infers: a Next.js app that ships a Dockerfile runs in a container.

The commit hasIt runs on
A wrangler.jsonc (or wrangler.toml) naming its worker's mainedge, from that config
A Dockerfilecontainer, built from it
A JavaScript framework (Next.js, React Router, Astro, Vite)edge
A server in another language (Python, Go, Ruby, Java, PHP), or a Node server with a start scriptcontainer, built by buildpacks
Only filesedge, served as they are

On the edge the app's server code runs in isolates near its visitors, and its static files are served from everywhere. A container runs the server in the app's region, listening on the port in PORT and scaling to nothing while no one visits. Each deployment names the runtime it ran on and why:

deployment = app.deploy().wait()
print(deployment.runtime, deployment.runtime_reason)   # container  a Dockerfile describes its server

A commit that moves the app to the other runtime is released there, and the app's URL follows it; the release before it stays where it ran, so promoting it is a rollback like any other. runtime="edge" or runtime="container" pins the app whatever its commits have (an agent can force a container for a long job), and a framework= names its runtime too (python runs in a container). A runtime that isn't available in the app's region fails the deployment, saying why, before anything builds. Apps created before auto keep the runtime they were made with until app.update(runtime="auto").

app = noclick.Project.from_name("reports", runtime="container", framework="python", create_if_missing=True)

Describe how your code runs in the standard formats, never one of NoClick's own. On the edge, a wrangler.jsonc naming its worker's main makes any repo a server app, whatever its framework, a plain site included. NoClick bundles the worker from that config and serves its assets.directory in front of it (files first, so the worker answers only paths no file does), honoring the folder's .assetsignore and assets.not_found_handling; it installs dependencies only when the repo has a package.json. A Next.js app needs nothing more than Next.js's own shape: NoClick builds it with OpenNext, and for each of the next.config, open-next.config.ts and wrangler config the repo doesn't have, the build uses OpenNext's default (your repo stays as it is); one the repo has is used as it is. The worker reaches the app's databases, storage, email and secrets through the SDK, never Cloudflare's own storage or account settings, which NoClick doesn't read. In a container, NoClick builds the repo with its own Dockerfile (framework="docker") or, without one, with buildpacks, as a python or node app when it detects one.

create makes an app outright and raises noclick.Conflict if the name is taken. Without repo=, it makes a new, empty repo of the same name for the app:

app = noclick.Project.create("status-page", framework="astro", user="acme")

Created inside as_user (or with user=), an app belongs to that user, and what it spends counts toward their spend cap. budget_usd_per_month gives the app a budget of its own, so one busy app can't spend all of it:

with noclick.as_user("user_42"):
    app = noclick.Project.from_name("booking", budget_usd_per_month=25, create_if_missing=True)

The budget is part of the definition like the rest: left out it's never changed. An app with code always has one, so None raises noclick.InvalidRequest: raise it instead. app.update(budget_usd_per_month=50) changes it from anywhere. Limits and billing says what it covers and what happens when it runs out.

region= places the app's server code near a region, for good (your account's default, else us-east): put it with the database it binds, so its queries stay short. Its static files are served from everywhere. The region says where the code runs, not where data stays; residency belongs to the database and bucket the app binds.

Deploy

deploy builds the production branch at its head and returns an operation. Once the build is ready, the app's URL serves it:

app = noclick.Project.from_name("booking")
deployment = app.deploy().wait()
print(deployment.status, deployment.url, deployment.commit)

A Deployment goes queued, building, then ready, failed or canceled. Pass commit= to deploy an earlier commit rather than the head. app.production is the deployment serving the URL now, and app.deployments() lists them all, newest first.

Preview a branch

Deploy any other branch and it gets a URL of its own, while production keeps serving what it did:

app = noclick.Project.from_name("booking")
repo = noclick.Repo.from_name("booking")
repo.commit({"app/routes/book.tsx": "export default function Book() { return null; }\n"},
            message="Try the new booking form", branch="new-form")

preview = app.deploy(branch="new-form").wait()
print(preview.url)   # https://new-form--booking.noclick.site

A preview's hostname is one DNS label, <branch>--<app>. A branch that's already lowercase letters, digits and -, and fits, is used as it is; any other is lowercased, with /, _ and anything else a hostname can't hold turned into -, shortened to fit, and given a short hash of its name, so two branches never share a URL: feature/New_Form serves at feature-new-form-7cb00e--booking.noclick.site. preview.url is where it serves; read it rather than building the name yourself. A branch's newest preview serves for 30 days after its deploy; after that its URL says the preview expired until the branch deploys again.

When you're happy with it (check it first), promote makes that build production without building it again, released with production's secrets:

preview.promote().wait()

Every promote bookmarks production's databases just before production switches, so undoing it (promoted.undo(), see Undo) restores them with the release before.

A preview gets the secrets set for previews (see Keep secrets), so it can use a test key where production uses a live one, and its own data: see A preview's own data.

A preview's own data

Each branch is an environment of its own. Its code names the same bindings and the same from_name names as production's, and reaches its own copy of each one, so nothing a preview writes ever reaches production's data. Promoting deploys the code, never the preview's data.

A copy is made the first time the branch writes to the resource. Until then the preview reads production's as it is now, read-only, so a branch that only reads costs nothing:

ResourceWhat the branch gets
Postgres databasea copy-on-write branch of production's data, at its first write (or its first writable connection URL)
SQLite databaseproduction's schema and migrations, none of its rows, at its first write; its rows too when the code asks (below)
Bucketan overlay, at its first write: what the branch writes and deletes is its own, and every other file reads through from production's
Dicta copy of its keys, the first time the branch uses it
Queuean empty queue of its own, the first time the branch uses it, so production's messages are never taken

Migrations follow the same rule: a preview's code migrates its copy, and production's database changes when production's code runs the migration, after you promote. Each copy is an ordinary resource you can list and open, named after the original and the branch (orders.new-form-3f9a1c), billed for what it stores and does like any other. A Postgres copy is a branch of production's database, which shares its storage until either changes: a database and its branches include 25 together, and each branch past that costs $1.50 a month, by the hour it exists, on top of its compute and storage. Copies of the other kinds cost only what they store and do.

A SQLite copy starts with the schema alone, so once the branch writes, it no longer sees production's rows. Give previews something to show in one of two ways:

# Sample rows: db/seed.sql, beside db/migrations/, runs once on the branch's fresh copy.
db = noclick.Database.from_name("orders", create_if_missing=True)
db.migrate("db/migrations/")

# Or production's rows themselves, copied at the branch's first write.
db = noclick.Database.from_name("orders", create_if_missing=True, branch_data="copy")

A seed runs after the migrations, in their transaction, on a branch's database that holds none of production's data (a SQLite copy without its rows, or a database only the branch has, of either engine). It runs once, shows in migrate's applied and the ledger as (seed), and never runs in production; pass seed= to name another file. branch_data="copy" copies production's rows from one moment's read while production keeps serving, for a database of up to 200,000 rows and 16 MB (past that the write that makes the copy raises noclick.QuotaExceeded, and a seed is the way). Asked for after the branch's copy was made without them, that copy goes to the trash and the branch's next write makes one with them. Rows copied are billed like rows written. A Postgres copy always holds production's data, so neither changes it.

A branch's copies go to the trash 72 hours after its last deploy, visit to its preview URL or call from its code, when the branch is deleted, and with the app; the next deploy starts it fresh. An account keeps at most 50 branches' environments, and a new one past that retires the one idle longest.

Apps made before branched previews share production's resources with their previews until you switch them:

app = noclick.Project.from_name("booking")
app.update(preview_resources="branch")   # or "shared": previews use production's
print(app.preview_resources)

What a preview reaches

A preview's code reaches nobody real. Each deployment's credential says which environment it runs in, and everything with an effect outside NoClick reads it:

ProductionA preview
Emailgoes to anyonegoes to you and the members of your workspaces; every other copy is kept in the preview's outbox, never sent or charged
Connectionsread and writeread; a write is refused unless the connection is marked a test account
Paymentsthe live keythe key set for previews, such as a test one
Agentstalks to them in the app's threadsreads the app's threads; starts no turn, since an agent acts for real
Anything else that reaches someone (an inbox's mail, a browser, a webhook test)as your own code wouldrefused
Calls and textsnot the app's to makenot the app's to make

Read what a preview would have sent with app.email.messages(environment="preview"). Promoting a preview releases it with production's credential, so the same build then reaches everyone.

Check it works

A build that's ready isn't one that works. check loads pages of a deployment where it serves (its preview URL, or the app's URL in production) in a browser, one after another, and says what each showed:

preview = app.deploy(branch="new-form").wait()
checked = preview.check(["/", "/book"]).wait()
print(checked.passed)
for page in checked.paths:
    print(page.path, page.status, page.load_ms, page.problem)
    for error in page.console_errors:
        print("  ", error["kind"], error["message"])
    print("  ", page.screenshot["url"])

A path fails when it answers 400 or more, doesn't finish loading within 30 seconds, or a script on it throws and nothing catches it. Other console errors (a console.error, a file that failed to load) are reported without failing it. Each screenshot is kept as one of the account's files, so the chat can show it. The check runs in a browser billed at its provider's price, by the second, and the deployment keeps its latest outcome as last_check.

Without paths it loads the app's check_paths, else its home page. Set them, and they gate every promote:

app.update(check_paths=["/", "/book"])
preview.promote().wait()   # checked first: raises noclick.OperationFailed if a path fails

A promote checks the release where it serves before production switches to it; one that fails isn't promoted, production keeps serving, and the operation's error carries the check. promote(force=True) puts it live without the check. A rollback to an earlier release (which no URL serves now) is checked once production serves it, and production goes back to what it served before if that fails.

requests() says how a deployment's real traffic went, from NoClick's record of each request it answered, its static files included: how many in the last 24 hours (or since since=, up to 7 days back), by status, the error rate (answers of 500 or more, and requests never answered) and the slowest routes:

outcomes = app.production.requests()
print(outcomes.requests, outcomes.error_rate, outcomes.by_status)   # 1204 0.0025 {'200': 1180, '404': 21, '500': 3}
for route in outcomes.slowest_routes:
    print(route.method, route.path, route.p95_ms)

It reads the newest 2,000 requests; past that sampled is True, as it is when a very busy app's record is a sample, each request in it counted for the ones it stands for. On the edge runtime a request's time runs until the app's answer begins.

When a build fails

A failed build fails the operation with noclick.OperationFailed, whose deployment_id names the deployment. Its build log says why. Production keeps serving the last good deployment.

app = noclick.Project.from_name("booking")
try:
    app.deploy().wait()
except noclick.OperationFailed as failed:
    broken = noclick.Deployment.get(failed.deployment_id)
    print(broken.error)
    for line in broken.logs(kind="build"):
        print(line.message)

cancel() stops a deployment that's still queued or building.

Roll back

rollback serves an earlier deployment again without rebuilding: one routing change, live within a minute:

app = noclick.Project.from_name("booking")
earlier = [d for d in app.deployments(branch="main") if d.status == "ready" and not d.production]
app.rollback(to=earlier[0]).wait()

Instant rollback covers the app's last 10 production deployments. One older than that was retired; rolling back to it builds its commit again, and the operation's wait() gives the new deployment. An app with check_paths checks the release once production serves it, and goes back if it fails (Check it works).

Deploy on push

deploy_on_push makes a push deploy by itself, whether it's a git push or a commit through the API: with production, every push to the production branch deploys it; with all, a push to any other branch deploys a preview of it too. It's off until you set it, so only deploy() deploys.

app = noclick.Project.from_name("booking", deploy_on_push="production", create_if_missing=True)
repo = noclick.Repo.from_name("booking")
repo.commit({"app/routes/book.tsx": "export default function Book() { return null; }\n"},
            message="Simpler booking form")
latest = app.deployments(limit=1)[0]
print(latest.started_by, latest.status)   # push queued

A deployment a push started says so in started_by; one deploy() started says deploy. Each push deploys its commit once, however often it's reported, and deploy() of a commit a push is already deploying gives that deployment's operation rather than building it twice. A push NoClick can't deploy, because the app's budget is used up or your account already has 4 builds going, is a failed deployment whose error says why, with a deployment.failed event, so nothing a push asked for disappears quietly. A deleted branch deploys nothing.

Give the app its data

A binding gives the running app a resource under a name. Bind a database, a bucket, a dict, a queue, a secret or a connection:

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("SEEN", noclick.Dict.from_name("processed-orders", create_if_missing=True))
app.bind("JOBS", noclick.Queue.from_name("webhook-buffer", create_if_missing=True))
app.bind("SHIPPING", noclick.Secret.from_name("shipping"))
app.bind("CRM", noclick.Connection.from_name("hubspot"))
app.deploy()

The deployed code finds each one in its environment under its name: DB to query, FILES to read and write files, SEEN and JOBS to use the dict and the queue, and CRM to call the connected account, with tokens NoClick keeps fresh and never hands to the code. A secret is the exception: each of its keys arrives as a variable of its own (SHIPPING_API_KEY). Binding a name again points it at something else, and unbind takes it away; the resource itself is never deleted. Every resource lists the apps that bind it in used_by, a connection and an agent included. An agent binds the same way.

for binding in app.bindings():
    print(binding.name, binding.kind, binding.resource_name)   # DB database booking-db

app.unbind("CRM")

Bindings, secrets and removals take effect at the next deployment.

Reach them from the app's code

Inside the running app, noclick.Project.current() is the app itself. NoClick puts the deployment's own credential in its environment (NOCLICK_DEPLOYMENT_TOKEN), so the app's code holds no key and the client signs in as the app. Each binding is then one call away by its name:

app = noclick.Project.current()
db = app.database("DB")
rows = db.query("select id, starts_at from bookings where day = ?", ["2026-10-02"])
app.bucket("FILES").put("invoices/1042.pdf", b"%PDF-...")
app.queue("JOBS").put({"booking": 1042, "send": "reminder"})
crm = app.connection("CRM")
app.email.send(to="ana@example.com", subject="Your booking", text="See you Friday at 3pm.")

database, bucket, dict, queue, connection and agent each return the resource bound under that name: an unbound name raises noclick.NotFound, and one bound to another kind raises noclick.InvalidRequest naming it. Outside a deployment, current() raises noclick.Unauthenticated; pass client= to act with a key of your own instead.

What the app's code can do

The deployment's credential is a key of the app's user (your account's, for an account app), so the app's code uses the whole SDK as that user: it makes agents and talks to them, makes databases and queries them, buckets, dicts, queues, inboxes, sandboxes, browsers and repos, calls noclick.AI, and reads and writes its user's other resources by id. Who may make the app do what is its own code's to decide, as on any server you write: NoClick doesn't hold its calls for anyone to approve, except a purchase in a person's name (below). The credential lives only in the release's server-side environment (a build never sees it, so no framework can put it in a page), and every deployment gets a new one.

helper = noclick.Agent.from_name("helper", instructions="Answer in French.", tools=["search"],
                                 create_if_missing=True)
reply = helper.message("Bonjour !", conversation="visitor-91c2")
The app's codeCanCan't
What its code madeeverything: use it, change it, delete it
What's bound to the appwrite its data, clearing or rewinding it included (dict.clear(), a queue's clear, a database's restore)rename it, reconfigure it or delete it: that stays with whoever made it
Its user's other resourcesread them, write their data (rows, files, keys, messages), talk to their agents, run their connectionsclear their data, change what they are, or delete them
Purchasesbuy a phone number or a domain, and renew its own, within what's left of its budget (below)buy in a preview; accept terms for a person; file documents, move numbers in from a carrier or register a business to text; turn on a domain's auto-renew; use quotes
What bills after its requestplace and answer calls and send texts on a number its code bought, run and build automations, host MCP servers, open channels and workspaces, run machines, issue cards: each counts toward the app's budget and waits on it before it runs (below)reach the account's automations and workspaces it didn't make (they're the account's, not its user's), or publish an automation's app
Its user's activityread that user's events, webhooks and usage, the files its own code uploaded, and its own requests and operationschange any of it; read another user's, another app's requests, operations and files, or the account's caps, tool calls, approvals, trash and policy
Another appreach it, its deployments, or anything its code made, by id, by name or named in a call's arguments; what its code made never shows in a list
Another userreach anything of theirs, or act for them: NoClick-User is refused, a list's user_id names only the app's own user, and an account app doesn't manage the account's users
Its own appread it (logs, usage, deployments), set its settings and schedules, send its emailchange what it is, deploy it, or delete it
Credentials and limitsmint, rotate or revoke keys, grants or sessions; set or raise any cap or budget, its own included; change policy; answer approvals; read raw tokens or set secrets' values (a secret bound to the app still reaches its environment)

An agent the app's code makes can't be given the noclick tool (your account's API) or the agents tools, nor be woken by your account's events, and the app's code never talks to an agent that holds the noclick tool, nor puts one behind an inbox or a number it sets up: the site's visitors, or whoever mails or calls, would then be driving your account. Another app's connection or volume, or anything its code made that from_name would find, is out of reach by name as it is by id. The one person such an agent may reach is you, the owner, to notify and ask ({"human": {"owner": true}, "tools": ["notify", "ask"]}, an account app's agent only), at most 10 times a day; past that its tool answers that it reached the limit. A refused call raises noclick.PermissionDenied, or noclick.NotFound for what another app made.

What it buys

A purchase from the app's code charges now what it costs now (a number's first month and any setup, a domain's term), and that must fit what's left of the app's budget this month, or the call raises noclick.SpendCapExceeded naming what's left in remaining_usd. What it buys is the app's own: its code manages and releases it, and each renewal counts toward the app's budget in the month it renews. A number whose renewal the budget can't cover keeps working for 48 hours while NoClick emails you that the app's budget can't cover it, then is released unless you raise the budget; a domain's renewal waits, is tried again each day until it expires, and you're told why.

A purchase that registers or files something in a person's name waits for that person: any domain (it's registered to someone, under the registrar's agreement), and a number whose country asks who holds it (one that asks for documents takes them from your own code: the app's code doesn't file them). The call raises noclick.ApprovalRequired with its approval_url, you approve it (accepting the terms there), and NoClick makes the call as the app, within its budget then. A plain US number needs nobody.

pick = noclick.PhoneNumber.available(area_code="415")[0]
number = noclick.PhoneNumber.buy(pick, max_monthly_usd=2)   # a US local number: charged to the app's budget

What bills after its request

Some work goes on spending after the call that started it. From the app's code, each is the app's, counted toward its budget and checked against it before it runs:

  • Calls and texts on a number its code bought: each call's minutes and the agent's turns on it, and each text. A call isn't placed or let in, and a text isn't sent, once the budget is used up.
  • Automations its code made: every run, whatever started it (a schedule, a webhook, a run from code), and every build its code starts. At the budget their triggers' deliveries are refused before anything runs.
  • A hosted MCP server its code made: each call its clients make.
  • A channel its code made: each turn its members take.
  • A machine its code bought: each day, while the budget covers it; past it, the machine runs on for 48 hours, then stops until the budget does.
  • A card its code made: each charge, which must fit what's left of the budget or is declined (over_budget).

Once the budget is used up, the sandboxes and browsers the app's code started are stopped too, and the email you get says so.

Find or make resources by name

The app's code can also name what it uses, so the code is the definition. from_name inside a deployment looks in the app's own namespace (its user's resources, or the account's for an account app) for the environment the code runs in:

db = noclick.Database.from_name("orders", create_if_missing=True)
db.migrate("migrations/")
seen = noclick.Dict.from_name("seen", create_if_missing=True)

A name bound to the app by hand wins. Otherwise production's code gets the resource of that name, made on first use with create_if_missing (within the app's budget), and it becomes one of the app's bindings, so it shows in app.bindings() and in the resource's used_by. A preview's code gets the branch's own copy of production's resource, or, when production has none yet, one only the branch has. What the app's code makes this way is the app's own, so its code may delete it; what it only finds, it uses and never deletes or renames: that stays yours.

Policy doesn't judge these calls. A binding is the app's own resource, given to it by whoever deployed it, and the app's code is yours; an agent the app messages still runs under its policy.

Everything the app does this way is billed to your account and attributed to the app and its user, and some of it stops when the money does: see When it runs out.

AI and agents in the app

app.ai is noclick.AI acting as the app: the same chat, embeddings, images and transcription, billed to your account, attributed to the app and its user, and held to the app's budget.

app = noclick.Project.current()
reply = app.ai.chat("Summarize in one line: the class was great but the room was cold.")
print(reply.text)

Code written for OpenAI's client works as the app too: give it app.ai.base_url and app.ai.headers(), which carry the deployment's credential rather than a key.

from openai import OpenAI

app = noclick.Project.current()
gpt = OpenAI(base_url=app.ai.base_url, api_key="-", default_headers=app.ai.headers())

Only the app acts as the app. On a project you reached with your own key, app.ai raises noclick.InvalidRequest; use noclick.AI there, inside as_user for the app's user.

An agent is bound like any resource: one of the app's own user, or one of your account's, which then acts for the app's user as message(user=...) would. app.agent(name) gives it to the app's code (an agent its code makes is talked to the same way):

app = noclick.Project.from_name("booking")
app.bind("SUPPORT", noclick.Agent.from_name("support"))
app.deploy()
app = noclick.Project.current()
support = app.agent("SUPPORT")
reply = support.message("Can I move my booking to Saturday?", conversation="visitor-91c2")
print(reply.text)

From the app, an agent its code didn't make is there to talk to, and nothing more:

  • The app's own conversations. message, send and ask start turns in the app's threads, and history and conversations read only those. A key the app passes, such as each visitor's id, always names one of the app's threads: visitor-91c2 from an app whose id is 3f9a… is app:3f9a…:visitor-91c2 to your own code, which reads it, and to the app's own code, which may pass either back. No other app, and no thread of yours, is ever reached that way. The key is the app's id, not its name: a deleted app's name is free for a new one, which mustn't read its threads.
  • Not its definition. update, delete, its triggers, memory, share page, wake-ups and children raise noclick.PermissionDenied from the app; change the agent from your own code. An agent the app's code made is the app's own: its code changes and deletes it too.
  • Its own rules. The agent's policy judges every tool call it makes, as always, and it acts in its own connections, never the app's bindings.
  • Billed like any turn. Its turns count toward the agent's budget, the app's budget and the user's cap, attributed to the app. So does every turn of an agent the app's code made, whatever started it (a trigger, a wake-up).
  • A preview's, in a simulated world. A preview's code may message an agent: the turn uses the agent's real model, prompt and thread, charged to the app, but every tool it calls answers from the same simulated world as a test run, it's offered nothing that reaches a person or money, and it writes nothing real. Its threads are its own (app-preview:3f9a…:visitor-91c2), apart from production's. ask, and every other turn, wait for production.

app.ai.chat(..., model="agent:support") reaches an agent the same way.

A chat assistant on a site is this, with one thread per visitor. The page keeps a visitor id (in localStorage) and posts each message with it; the app's server answers:

import { Project } from "noclick";

export default {
  async fetch(request: Request, env: { ASSETS: { fetch(r: Request): Promise<Response> } }) {
    const url = new URL(request.url);
    if (request.method !== "POST" || url.pathname !== "/api/chat") return env.ASSETS.fetch(request);
    const { visitorId, text } = (await request.json()) as { visitorId: string; text: string };
    const assistant = await Project.current().agent("ASSISTANT");
    const reply = await assistant.message(text, { conversation: visitorId });
    return Response.json({ text: reply.text });
  },
};

Keep secrets

A secret is a value the app reads from its environment by name, such as an API key. Values are write-only: nothing, including the console, reads one back.

app = noclick.Project.from_name("booking")
app.secrets.set("MAPS_API_KEY", "...")

for name, updated_at in app.secrets.list().items():
    print(name, updated_at)

app.secrets.delete("OLD_WEBHOOK_SECRET")

A secret applies to every deployment unless you set it for one environment: production (deploys of the production branch, and what's promoted) or preview (every other branch). Give previews a test key and production a live one under the same name:

app = noclick.Project.from_name("booking")
app.secrets.set("STRIPE_KEY", "sk_live_...", environment="production")
app.secrets.set("STRIPE_KEY", "sk_test_...", environment="preview")

A name is letters, digits and _, starting with a letter; a value is at most 5 KB, and an app's environment holds 128 values in all, NoClick's own and each bound secret's keys included. Keep anything larger in a bucket the app reads. Export lists secret names only.

Plain settings

What isn't a secret, such as a feature flag or the app's public URL, goes in app.settings. Settings reach the app's environment the same way, but read back and show in the console:

app = noclick.Project.from_name("booking")
app.settings.set("PUBLIC_BASE_URL", "https://book.acme.com")
print(app.settings.list())   # {'PUBLIC_BASE_URL': 'https://book.acme.com'}
app.settings.delete("PUBLIC_BASE_URL")

A setting's name follows a secret's rules and can't be one of the app's secret names; a value is at most 4 KB. Like secrets, settings apply from the next deployment.

Run on a schedule

schedule has NoClick POST one of the app's paths on a Cron, in the timezone you give it:

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)

NoClick POSTs the path on the app's own hostname, so a domain that isn't live yet never gets in the way, and waits up to 30 seconds for the answer. Each request carries a NoClick-Signature header, so the app can refuse anyone else (see Check a scheduled request), and a 2xx answer counts as a success; a redirect counts as a failure. Each tick runs once, however often the scheduler retries delivering it. A schedule is keyed by its name, the path unless you pass one, so calling schedule again with the same one updates it instead of adding another. That makes it safe to run on every deploy. tz="user" reads the expression in the app's user's timezone, following them when they move; an app of your account's own has no user, so it names a zone.

last_status and last_error say how the latest run went. When 5 runs in a row fail the same way over at least 30 minutes, a schedule pauses itself: its status is paused, paused_reason says what kept failing, and the schedule.paused event carries the schedule. Calling schedule again while it's paused changes what it will do and leaves it paused. Fix the app, 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_now() calls the path once, outside the schedule, and waits for the answer; a run you start by hand never counts toward pausing it. pause() and delete() do what they say.

Check a scheduled request

The request's body is JSON naming the run: {"object": "schedule_run", "id", "schedule_id", "schedule", "path", "scheduled_at"}, where id is the same for every delivery of one tick, so a handler that must act once can key on it. verify_request checks the signature in the app's own code, with the signing secret NoClick puts in the deployment's environment (NOCLICK_SIGNING_SECRET). Pass the raw body, exactly as it arrived, and the headers. In a Flask 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 "", 204

An edge app does the same in its route with the TypeScript client: await Project.current().verifyRequest(await request.text(), request.headers).

The header is t=<unix seconds>,v1=<hex>, the hex being HMAC-SHA256 of "<t>.<body>", as on webhook deliveries; a timestamp more than five minutes from now is refused, so a captured request can't be replayed.

Add a domain

An app serves at https://<name>.noclick.site from the start, or at <name>-<6 characters> when another app holds the name or it's one NoClick keeps for itself (www, api, mail, bounce and a few like them); app.url says which. Add a domain you bought through NoClick, or one you own elsewhere:

app = noclick.Project.from_name("booking")
domain = app.add_domain("acmebooking.com")
print(domain.status)   # active for a bought domain; pending_dns until the records are set

A bought domain serves the app at its name and at www, and NoClick writes their records. A name nobody holds yet is connected for the app, in the app's scope, and records lists what to add at its DNS host (see Connect a domain). Each name is live once its certificate is issued, and from then on app.url is the first of them. app.domains() lists the domains, and app.domain_names names the live hostnames without loading each one.

remove_domain stops serving the app at a name; the domain stays yours, and one connected for the app stays connected until you delete it. A domain can't be disconnected while an app is served there:

app = noclick.Project.from_name("booking")
app.remove_domain("acmebooking.com")

A subdomain of yours for each user's app

A subdomain of a domain your account holds can serve any project, a user's included, so each user's app gets an address of yours:

store = noclick.Project.from_name("store", user="user_42")
domain = store.add_domain("ana.acmebooking.com")

Only a key acting for the whole account (not a user's session, nor a key pinned to a user) may hand one out, and a user's app takes a name under your domain, never the domain itself. Under a domain you bought through NoClick, the record is written for you and the subdomain is active as soon as its certificate is issued, usually within a few minutes. Under a connected one, when the domain was connected with Domain.connect(name, wildcard=True), the subdomain needs no record of its own and is live once its certificate is issued, usually within minutes; otherwise its records list the one to add. The same goes for an app's email: app.email.enable(domain="mail.ana.acmebooking.com").

Logs and usage

logs reads what the app printed while serving, oldest first: the last limit lines, or with since the ones after a moment, so you can read on from the last line you saw. level="error" keeps only errors.

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)

used = app.usage()
print(used.requests, used.cpu_ms, used.usd)   # this month, from period_start
print(used.budget_usd, used.limited_by)       # 25.0 None
print(used.by_type)                           # {'apps': 1.84, 'ai': 6.2, 'model_tokens': 3.1}

usd is everything the app spent this month: hosting and builds, its calls to what it's bound to, its email, app.ai and its agents' turns, split in by_type by the types of Usage.series. limited_by says what refuses its paid calls now: project (its budget), user (its user's cap), account (your balance), or None while they go through.

A deployment's own logs(kind="build") holds its build output, logs(kind="runtime") what that one deployment printed, and requests() how its requests went (Check it works). A paid call NoClick refuses is in the log too, as an error line naming the call and what ran out, at most once a minute for each kind of call, so a refusal the code caught still shows.

How it's doing

analytics reads the app's traffic: its requests each day of the last week (period="24h" hour by hour, "30d" the last 30 days), how many failed, the same length of time before to compare with, by status, how long answers took to begin, and its busiest paths:

app = noclick.Project.from_name("booking")
week = app.analytics(tz="Europe/Paris")
print(week.requests, week.previous_requests)       # 1204 980
print(week.error_rate, week.p50_ms, week.p95_ms)   # 0.0025 42.0 310.5
for day in week.series:
    print(day.at.date(), day.requests, day.errors)
for page in week.top_paths:
    print(page.path, page.requests, page.p95_ms)

It counts every request the app's sites answered, from NoClick's record of each one: pages, static files and API calls, from people and bots alike. They're requests, not visitors: a page that loads twenty files is twenty-one requests. A failed request is an answer of 500 or more, or none at all. Hours and days start in tz (an IANA time zone, UTC when omitted), and the last one is still running. Only the production site is counted unless you pass environment="preview" (its previews) or "all". sampled is True when a very busy app's record is a sample, each request in it counted for the ones it stands for. The app's page under Resources shows the same.

Every app's requests at once come from one read, however many apps there are: each production site's over the same window analytics reads, newest app first, one nobody reached at 0. The Websites list under Resources shows each app's week this way.

week = noclick.Project.analytics_summary(tz="Europe/Paris")   # period="7d" by default
for line in week.apps:
    print(line.project_id, line.requests, line.errors)
print(week.of(app).requests)

Email, payments and sign-in

An app can send transactional email, take payments into an account its owner claims, and sign its own users in, through app.email, app.payments and app.sign_in. See Email, payments and sign-in.

Apps NoClick publishes

Publishing an automation's interface in NoClick makes an app too, listed with yours: published is True, repo_id is None, and workflow names the automation it was published from. Each publish is a deployment of it (no commit, no build), so its deployments, logs, usage and domains read like any app's, and a domain you add serves it. It has no repo of its own, so deploy, delete and every setting but budget_usd_per_month raise noclick.Conflict: it's republished, renamed or unpublished from its automation.

for app in noclick.Project.list():
    if app.published:
        print(app.url, "from", app.workflow["name"] if app.workflow else "a page published before apps")

Export and delete

export packages everything the app is, to run it somewhere else: its code as a git bundle, apps/<name>.json with its settings, its bindings and the names of its secrets (set their values again wherever it runs next), and each database, bucket, dict and queue it binds, each in its own export format. To put the code on GitHub, copy its repo there. It's an operation that gives an export bundle:

app = noclick.Project.from_name("booking")
bundle = app.export().wait()
for item in bundle.contents:
    print(item.kind, item.name, item.detail)

delete takes the app offline within seconds and moves it to the trash, where it stays restorable for 30 days: its deployments, settings, secrets, bindings and schedules are kept, its domains are detached (a bought one stays yours), and its name is free meanwhile. The repo and every database and bucket it bound are kept; delete_data=True moves the repo to the trash too, with each database and bucket no other app binds.

item = noclick.Project.from_name("status-page", user="acme").delete()
item.restore()   # served again where it was, from the same release: no build

A restore serves the release that was live, its previews and its domains again, within about 30 seconds, and its schedules fire again. It's refused with noclick.Conflict while a new app has its name or its address, while one of its domains serves something else, or while its repo is in the trash (restore the repo first); a domain you've disconnected since doesn't come back, and the restore's domains_not_restored names it. Purging it deletes its releases for good, and purging its repo purges it too.

NoClick's badge

On the Free plan, each page an app of your account's own serves in production carries a small "Built with NoClick" link in its bottom-right corner, at every address it serves, your own domains included. A paid plan drops it. NoClick adds it as each page is served, just before the page's </body>, never to your repo or what the build made, so a plan change shows within a few minutes without a deploy. Previews, your users' apps and anything but a page a browser opens (JSON, a page your code fetches, a frame) never carry it.

Limits and billing

LimitValue
Apps per account100
Builds running at once4 per account
One build15 minutes
Instant rollbackthe last 10 production deployments
A branch's previewserves 30 days after its deploy
Memory per request (edge)128 MB
CPU time per request (edge)30 seconds
A container (container)1 vCPU and 512 MB, up to 10 at once, none while idle
A request (container)5 minutes
Runtime logskept 7 days
Schedules per app20
A schedule's request30 seconds
One secret5 KB
Values in an app's environment128

Your account pays for everything an app does, attributed to the app and the user it belongs to: the requests it serves and the CPU time it uses (previews cost the same as production while they serve), its builds by the second, what its calls to its bindings cost at their own prices, its email, app.ai, and the turns of the agents it messages. What a bound database, bucket or dict stores belongs to that resource and counts toward its own user, not the app, since two apps can bind one database. noclick.usage(group_by="project") gives each app's spend this month, to bill its user on.

Hosting and builds cost what NoClick's providers charge for them. Every request an app answers costs $1.90 a million on its way in, whatever answers it, and what runs its code is added:

edgecontainer
A million requests$1.90$1.90
A million requests its code answers, added$0.60 (a static file runs no code: nothing added)$0.40
Compute$0.02 a million ms of CPU time$0.0909 an hour of the container serving requests ($0.12726 in ap)
A build, by the second$0.0031656 a minute ($0.0055308 for Next.js)$0.006 a minute

The way in also uses a little CPU time of its own, shared out by request at $0.02 a million ms, and a request the app never answers adds $0.60 a million for the error it logs.

All of it counts toward the cap of the user the app belongs to, and toward the app's budget_usd_per_month: what its code spends, the turns of the agents it messages or made (whatever started them), and the uptime of the sandboxes and browsers it made. noclick.Usage.limits() lists each app with a budget and what it has spent.

Its monthly budget

Every app made through the API has one, so a bug or an attack can't run up your bill: the budget_usd_per_month its maker sets, else $10. An agent making a site for you sets one that fits what it's for and says so (NoClick's own sets a plain site's at $5, one with an assistant at $20, and one that buys a number or makes calls higher, such as $100). Only you change it afterwards, in the app's page under Resources, the console, or app.update; an agent can't raise it, and neither can the app's own code.

An app the app's code makes nests under it: what the new app spends counts toward the budget of the app that made it too, all the way up, and the budget it's given (and any budget, card limit or spend its code gives what it makes: an agent, a channel, a workspace) is at most what the maker has left this month. So an app's code can never spend past its own budget by making another.

$10 is about what a chat assistant's 800 turns cost (an agent's turn is about a cent on average); serving a site costs a fraction of a cent a day. Raise it for an app that does more; an app with code always has one, since its code acts with a key of your user's. An app NoClick publishes from an automation has none until you set one.

app = noclick.Project.from_name("booking", repo=repo, create_if_missing=True, budget_usd_per_month=20)
print(app.budget_usd_per_month)   # 20.0
app.update(budget_usd_per_month=50)

When it runs out

Once the app's budget, its user's cap or your account's balance is used up, NoClick stops what the app spends without taking it offline: a customer's site going dark is worse than a month that ends a little over budget.

  • It keeps serving. Pages, API routes and scheduled requests run, and so does every call that reads what the app already has. Serving costs $1.90 a million requests, so only traffic the site was never built for (a flood) costs much more: once the month's spend reaches twice the budget, its pages pause too, and serve again when the budget is raised or the month turns.
  • Its paid calls fail before doing anything, with noclick.SpendCapExceeded (scope is project for its budget, user for its user's cap) or noclick.PaymentRequired (your balance). The error names the app in project and project_id, says what ran out with its cap and spent, and its next_step says how to lift it: raise the budget with app.update, raise the user's cap, add funds, or wait for the month to turn.
  • Deploys are refused with the same error, and production keeps serving the last good deployment.
  • What spends on its own stops. The sandboxes and browsers its code started stop, its automations' triggers are refused, a call isn't let in and a text isn't sent, a card's charge is declined, and a machine or a number whose renewal the budget can't cover gets 48 hours before it stops or is released (above).
  • You hear, not your customer. NoClick emails your account's owner once when the app reaches its budget in a month, saying which app, what stopped and when it starts again, and once more if its pages pause, and emits a spend.cap_reached event naming the app and its user. NoClick never writes to your users about money: whether and how to tell them is yours to decide, since lifting the limit is too.
What the app callsKeeps workingFails: its paid calls
Databasequery, schemaexecute, batch, and a query that calls noclick.embed
Bucketread, head, objects, url, a download link, removeput, an upload link
Dictget, keys, items, pop, remove, clearput, put_if_absent, increment, update
Queueget, iterate, ack, nack, release, len, peekput, put_many
Secretits keys, in the environment
Connectionlookuprun
Agenthistory, conversationsmessage, send, ask
app.emailstatus, messagessend, broadcast
app.aimodelschat, embed, image, transcribe

The rule underneath: what reads what the app already has keeps working, so the site stays up and shows its data, and what adds something (a write, a message, a model call, a turn) fails. Keeping working isn't the same as free. Queue reads, secrets and settings cost nothing, as do downloads' bytes, but database rows read, dict reads and file requests are billed at their resource's price even at a cap. They're bounded by the traffic its hosting serves, so usd can end the month past the budget by what serving and reading cost; usage() shows by how much.

Catch the two errors where the app makes a paid call, and serve the page without it:

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 = None   # the page shows the reviews without a summary

Errors

ErrorWhen
NotFoundno app, deployment or schedule by that name or id for the user the call acts for, a branch or commit the repo doesn't have, a domain that doesn't serve the app, or, from an app's code, something another app's code made or an operation it didn't start
Conflictthe name is taken, the domain belongs to another NoClick account, or, from an app's code, a name it resolves names what another app's code made
InvalidRequesta branch name that isn't valid, a secret name that isn't valid, a cron expression that isn't, or app.ai on a project reached with your own key
OperationFaileda build failed; deployment_id names it
QuotaExceededthe account has as many apps, or builds running, as it can
SpendCapExceededthe app's budget or its user's cap is used up: a deploy is refused, and at run time so are its paid calls (When it runs out)
PaymentRequiredyour account's balance is used up: the same
PermissionDeniedthe app's code made a call it never makes (What the app's code can do), such as changing or deleting what it didn't make, or a call not acting for your whole account handed out a name under your domain

Every method is in the Project reference: deploy, bind, update, schedule, logs, usage, and Deployment, Schedule and ProjectAI with theirs.