Dicts
A key-value store for small shared state, what has been seen, counters, locks and caches with expiry, that reads like a Python dict.
A dict is a key-value store NoClick keeps for you: string keys, JSON values, and an expiry per key if you want one. It's for the small pieces of state your code and agents share: which orders you've already handled, a counter, a lock so a job runs once, a cache that empties itself. Every read and write goes to the API, so a web app, a worker and an agent see each other's changes at once, with nothing to host.
Values are JSON (a dict, list, string, number, boolean or None), never pickled, so what your Python code writes reads the same from TypeScript, from an agent, or in the console.
Create a dict
Make your code the definition: from_name with create_if_missing=True creates the dict the first time it's used, and finds it every time after.
import noclick
seen = noclick.Dict.from_name("processed-orders", create_if_missing=True)create makes one outright, and raises noclick.Conflict if the name is taken (its existing_id names the one that's there). A name is 1 to 64 letters, digits and . _ -, unique for each user, and for your account; created inside as_user, a dict belongs to that user and bills to it:
with noclick.as_user("acme"):
flags = noclick.Dict.create("feature-flags")region= keeps its keys in a region, for good (your account's default, else us-east). Dicts are kept in NoClick's own database in us-east for now, so that's the one region they take: another, or an account default region elsewhere, raises noclick.InvalidRequest naming it, and you pass region="us-east" to keep the dict there.
Read and write keys
A dict reads like a Python dict:
seen = noclick.Dict.from_name("processed-orders", create_if_missing=True)
seen["o_1841"] = {"status": "refunded"}
print(seen["o_1841"]["status"]) # refunded
print("o_1842" in seen) # False
print(seen.get("o_1842", default="new"))
del seen["o_1841"]
print(len(seen))A missing key raises KeyError on seen[key] and del seen[key]; get returns its default instead (None unless you give one). pop(key, default) removes a key and returns its value, and remove(key) removes one and says whether it was there. len(seen) counts the keys now; seen.key_count is the count as of the last load.
Each of these is a call, never a local copy, so there's no save step and no stale read. In async code, use the method forms: await seen.get.aio(key), await seen.put.aio(key, value), await seen.pop.aio(key, None).
Keys are non-empty strings. A value that isn't JSON, like a datetime or a set, raises TypeError before anything is sent; convert it first (a datetime to an ISO string, say). A tuple comes back as a list.
Expire keys
put writes a key like seen[key] = value does, and takes ttl_s: the key disappears that many seconds later.
cache = noclick.Dict.from_name("rates-cache", create_if_missing=True)
cache.put("usd:eur", 0.92, ttl_s=86400)A put without ttl_s keeps the key until it's removed, and clears any expiry it had. An expired key is gone for every read: get returns the default, in is false, and it no longer counts in len.
Take a lock, or run something once
put_if_absent writes a key only if it isn't there, in one atomic step: of any number of callers racing for the same key, exactly one gets True.
locks = noclick.Dict.from_name("locks", create_if_missing=True)
if locks.put_if_absent("nightly-report", "worker-3", ttl_s=300):
print("This worker runs the report")With ttl_s, a lock whose holder crashes frees itself; locks.remove("nightly-report") frees it early. The same call makes a webhook handler safe to retry, by recording each delivery id the first time:
seen = noclick.Dict.from_name("processed-orders", create_if_missing=True)
if seen.put_if_absent("evt_9f2c", True, ttl_s=7 * 86400):
print("First delivery: handle it")Count
increment adds to a counter atomically, so concurrent callers never lose a count, and returns the new value. A missing key starts at 0, and by can be negative.
stats = noclick.Dict.from_name("stats", create_if_missing=True)
total = stats.increment("refunds-today")
limits = noclick.Dict.from_name("rate-limits", create_if_missing=True)
if limits.increment("calls:acme:2026-09-28T14", ttl_s=3600) > 100:
print("Over the limit; try again next hour")ttl_s on increment sets the expiry only when the call creates the key, so a counter per window cleans itself up. A key that holds anything but an integer raises noclick.InvalidRequest.
Write many at once
update writes up to 1,000 keys in one call, all of them or none, with an optional ttl_s for each:
flags = noclick.Dict.from_name("feature-flags", create_if_missing=True)
flags.update({"new-checkout": True, "beta-search": False, "max-upload-mb": 50})List keys and values
keys and items list in key order, optionally only the keys that start with a prefix. Iterating the dict itself walks every key a page at a time.
seen = noclick.Dict.from_name("processed-orders")
for key in seen.keys(prefix="lock:"):
print(key)
for key, value in seen.items(prefix="o_", limit=100):
print(key, value)
for item in seen.items(prefix="lock:"):
print(item.key, item.expires_at)Each DictItem unpacks like a pair, so dict(seen.items()) works, and also carries expires_at, updated_at and size_bytes. Without a limit, keys and items fetch everything, page by page; for a big dict, use items_page(prefix=..., cursor=...) a page at a time, or iterate the dict. A prefix scheme (lock:, o_, cache:) keeps one dict tidy for several uses.
Use it from an app
An app reaches a dict through a binding: app.bind("SEEN", seen) makes it available to the deployed code as SEEN, and the dict'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 gets a copy of the dict's keys the first time it uses it, and writes only to that. See A preview's own data.
A dict holds data your code reads and writes. What an agent should carry from one conversation to the next, in its own words, belongs in its memory instead.
Clear, export and delete
cache = noclick.Dict.from_name("rates-cache")
cleared = cache.clear() # every key gone; the dict stays
cleared.undo() # changed your mind: the keys are back
item = cache.delete() # to the trash, keys and all
item.restore()delete moves the dict and its keys to the trash, restorable for 30 days. Every write is logged with the value it replaced, so one key can be put back too: see Undo and the trash.
seen = noclick.Dict.from_name("processed-orders")
bundle = seen.export().wait() # JSON Lines: one key, value and expiry per line
print(bundle.url)export is an operation that gives an export bundle: dicts/<name>.jsonl, every key as the dict stood at one moment, in key order, one {"key", "value", "expires_at"} per line.
Limits and billing
| Limit | Value |
|---|---|
| Key | 512 bytes of UTF-8 |
| Value | 1 MB as JSON |
One update | 1,000 keys |
| A dict | 100 MB of keys and values |
| Your account's dicts and queues together | 1 GB |
| Dicts per account, its users' included | 1,000 |
| Expiry | 1 second to 365 days |
Dicts cost nothing within these limits. Metered pricing, by the GB-month stored and per thousand reads and writes, will come with larger limits. For rows you query with SQL, use a database; for files, a bucket; for work to hand off, a queue.
Errors
| Error | When |
|---|---|
KeyError | seen[key], del seen[key] or pop(key) without a default, on a missing or expired key |
TypeError | a value that isn't JSON, or a key that isn't a non-empty string |
InvalidRequest | increment on a key that isn't an integer; a key or value over its limit, a value that holds NaN or a NUL character; a region dicts aren't kept in |
Conflict | the name is taken |
QuotaExceeded | the dict would pass 100 MB, or your account's dicts and queues 1 GB; a 1,001st dict |
NotFound | no dict by that name or id, for the user the call acts for |
Every method is in the Dict reference: get, put, put_if_absent, increment, items and the rest.