Storage
Buckets of files by key, private by default, with signed links, public URLs and S3-compatible access.
A bucket holds files by key: uploads, generated reports, images, anything that isn't rows in a database. A bucket is private by default. Your code reads and writes it with your key, hands a file to someone with a signed link that expires, or makes the whole bucket public for assets a site serves. An agent can keep its files in one, as its tools.
Create a bucket
import noclick
bucket = noclick.Bucket.from_name("uploads", create_if_missing=True)from_name with create_if_missing=True creates the bucket on first use and finds it after that; one that already exists is used as it is. create makes one outright and raises noclick.Conflict if the name is taken. A name is 1 to 64 letters, digits and . _ -. Pass public=True for a bucket whose files anyone can read:
assets = noclick.Bucket.create("public-assets", public=True)A name is unique for each user, and for your account. Created inside as_user, a bucket belongs to that user and bills to it, and a call acting for that user reaches it (a call for the account reaches the account's). See Users.
region= places its files in a region, for good (your account's default, else us-east): region="eu" for files kept in Europe. Object storage takes the region as a location hint, making the bucket there when it has room there, so it's where your files are kept rather than a legal promise of residency.
Store and read files
A key is a path-like name, such as invoices/0042.pdf. put stores bytes, text or an open binary file at a key, replacing what was there:
import noclick
bucket = noclick.Bucket.from_name("uploads", create_if_missing=True)
stored = bucket.put("invoices/0042.pdf", b"%PDF-1.7 ...", content_type="application/pdf")
print(stored.size, stored.etag)
with open("report.csv", "rb") as f:
bucket.put("reports/2026-09.csv", f, metadata={"user": "acme", "source": "nightly"})content_type is guessed from the key's extension when you leave it out. metadata is up to 2 KB of your own ASCII string pairs, kept with the file (their names come back lowercased). One put carries up to 100 MB; send bigger files straight from where they are with a signed upload link (below).
pdf = bucket.read("invoices/0042.pdf") # bytes
info = bucket.head("invoices/0042.pdf") # size, content_type, etag, metadata, updated_at
bucket.remove("invoices/0042.pdf")read and head raise noclick.NotFound for a key that isn't there. remove on a missing key does nothing, so it's safe to repeat.
List files
objects lists the files whose keys start with a prefix, in key order:
for obj in bucket.objects(prefix="invoices/"):
print(obj.key, obj.size, obj.updated_at)A / in a key is only part of its name, but you can walk a bucket like folders: with delimiter="/", everything below the next / is rolled up into one entry whose is_folder is true.
for entry in bucket.objects(prefix="invoices/", delimiter="/"):
print(entry.key, "folder" if entry.is_folder else entry.size)objects fetches every page for you (pass limit to stop early). objects_page gives one page at a time, with a next_cursor to pass back as cursor.
Hand out a link
A private file is reached with a signed link: a URL that works without a NoClick key until it expires. Give one to a user to download a file:
link = bucket.signed_url("invoices/0042.pdf", expires_in=3600)A PUT link lets a browser upload straight into the bucket, so the file never passes through your server. With content_type, the upload must send that type:
upload = bucket.signed_url("avatars/u42.png", method="PUT", expires_in=600, content_type="image/png")await fetch(upload, { method: "PUT", body: file, headers: { "Content-Type": "image/png" } });A link lasts from 60 seconds to a week, and anyone who has it can use it until then, so hand it only to whoever needs it. A PUT link takes up to 5 GB, so it's also how your own code stores a file bigger than one put carries, such as a two-hour recording to transcribe:
import httpx
import noclick
recordings = noclick.Bucket.from_name("recordings", create_if_missing=True)
upload = recordings.signed_url("calls/2026-09-30.mp3", method="PUT", expires_in=3600, content_type="audio/mpeg")
with open("call.mp3", "rb") as f:
httpx.put(upload, content=f, headers={"Content-Type": "audio/mpeg"}).raise_for_status()
words = noclick.AI.transcribe(recordings.signed_url("calls/2026-09-30.mp3"))Tell a queue about new files
notify puts a message on a queue for every file stored under a prefix, however it arrived: put, a PUT link or S3 tools. A worker, or an agent with an OnQueue trigger, then picks each upload up:
import noclick
recordings = noclick.Bucket.from_name("recordings", create_if_missing=True)
new_calls = noclick.Queue.from_name("new-recordings", create_if_missing=True)
recordings.notify(new_calls, prefix="calls/")
for msg in new_calls.iterate():
link = recordings.signed_url(msg.body["key"])
print(noclick.AI.transcribe(link).text)
msg.ack()Each message is {"event": "put", "bucket", "key", "size", "content_type", "etag", "at"}. The queue is a Queue or its name, in the bucket's own user. Asking again for the same queue and prefix changes nothing, notify(queue, prefix=..., enabled=False) stops it, and the bucket's notifications lists where it tells, up to 10 queues. A file stored twice at one key is two messages; a removed file is none.
Serve files publicly
In a public bucket, every file has a plain URL that never expires, under the bucket's public_base_url. It's NoClick's address, which sends the browser on to the file with a short-lived signed link:
import noclick
assets = noclick.Bucket.from_name("public-assets", create_if_missing=True, public=True)
with open("logo.png", "rb") as f:
assets.put("logo.png", f)
print(assets.url("logo.png"))url on a private bucket raises noclick.NotAvailable. update(public=True) opens a bucket; update(public=False) closes it, and every public URL stops working at once (signed links, and the short-lived links a public URL already sent a browser to, keep working until they expire).
Use S3 tools
For rclone, the AWS CLI, or an S3 library, s3_credentials mints keys for the bucket's S3-compatible endpoint. They reach this bucket only, read_only=True keeps them from changing anything, and they stop working after ttl_s seconds:
import boto3
import noclick
bucket = noclick.Bucket.from_name("uploads")
creds = bucket.s3_credentials(read_only=True, ttl_s=3600)
s3 = boto3.client(
"s3",
endpoint_url=creds.endpoint,
region_name=creds.region,
aws_access_key_id=creds.access_key_id,
aws_secret_access_key=creds.secret_access_key,
aws_session_token=creds.session_token,
)
print(s3.list_objects_v2(Bucket=creds.bucket, Prefix="invoices/")["KeyCount"])Use creds.bucket, the bucket's name on the S3 endpoint, rather than its NoClick name. The secret is shown once; mint new credentials when these expire. S3 tools are also how you move files larger than a PUT link allows: they upload in parts.
Use it from an app or an agent
An app reaches its bucket through a binding: app.bind("FILES", bucket) makes it available to the deployed code as FILES, and the bucket's used_by lists the apps that bind it; the app's code can also find or make it by name with from_name. See Apps.
A preview's code reads production's files and keeps what it writes: at its first write the branch gets an overlay bucket of its own, holding what it puts and remembering what it deletes, and every other key reads through to production's, links and public URLs included. Production's files never change. See A preview's own data.
An agent can keep the files it makes, one bucket per user, by creating it inside as_user:
import noclick
with noclick.as_user("user_42"):
files = noclick.Bucket.from_name("assistant-files", create_if_missing=True)
files.put("receipts/2026-09-12.txt", "Coffee, $4.50")Put the bucket in an agent's tools (a bucket of the agent's own user, or your account's) and the model gets five tools named after it: uploads__objects (list keys under a prefix), uploads__head (a file's size, type and metadata), uploads__put (store a file), uploads__signed_url (a link that reads a file, to hand a person) and uploads__remove. put takes the file from a url or as text, with a content_type and metadata, so no bytes pass through the model: a file the agent's browser downloads comes back to it as a signed link, which put stores from url. only(...) gives it some of them, and bind(prefix=...) keeps every key it reads or writes under one folder, with keys given and returned relative to it:
import noclick
uploads = noclick.Bucket.from_name("uploads")
assistant = noclick.Agent.from_name(
"assistant",
create_if_missing=True,
instructions="You file what the user sends you and share links to it.",
tools=[uploads.only("objects", "put", "signed_url").bind(prefix="users/{{user.id}}/")],
)One bucket then holds a folder per user, and no user's agent reaches another's: a turn for someone who isn't one of your users (the account itself, a stranger's email) can't fill {{user.id}}, so it isn't offered those tools at all. Its calls follow your approval rules as bucket.objects, bucket.head, bucket.put, bucket.signed_url and bucket.remove (bucket.*). See Fix arguments the model mustn't choose for what a binding may name. A bucket in a toolkit or a hosted MCP server is in invite-only beta.
Export and delete
export is an operation that gives an export bundle: a zip, at a link valid 7 days, holding every file at its key under buckets/<name>/files/, and index.jsonl listing each key with its content type and metadata. A key that can't be a path in a zip (an empty, . or .. part, a backslash) is kept under renamed/, and the index says which key it is. Files' earlier versions aren't files, so they aren't in it.
bundle = bucket.export().wait()
print(bundle.url, bundle.size_bytes)delete moves the bucket to the trash. It must hold no files, or pass force=True to take its files with it; a bucket that still holds files raises noclick.Conflict, as does one in an agent's tools (its used_by lists them). Files' earlier versions don't count: a bucket whose files you've all removed deletes without force, its versions go to the trash with it, and they come back if you restore it.
noclick.Bucket.from_name("scratch").delete(force=True)Limits and billing
| Limit | Value |
|---|---|
One put | 100 MB |
One PUT link upload | 5 GB |
| Signed link lifetime | 60 seconds to 7 days |
| Metadata per file | 2 KB |
A bucket is billed for what it stores, $0.015 per GB-month, charged by the hour. NoClick measures it within a minute or so of a change your code or an agent makes, and at least every hour (so a file uploaded with a signed link is counted within the hour); size_bytes and object_count are that measurement. Requests and downloads cost nothing extra. Spend counts toward the cap of the user the bucket belongs to.
Errors
| Error | When |
|---|---|
NotFound | no file at that key, or no bucket by that name or id for the user the call acts for |
NotAvailable | url on a private bucket |
Conflict | the name is taken; delete on a bucket with files (pass force=True) or one in an agent's tools |
InvalidRequest | a file over 100 MB, metadata over 2 KB or not ASCII, a name that isn't allowed, or a link lifetime out of range |
Every method is in the Bucket reference: put, objects, signed_url, s3_credentials and the rest.