Undo and the trash
Deleting an app or a resource moves it to the trash for 30 days, a reversible write records its undo, and a dict, a secret or a setting keeps what it held. Only purging is permanent.
NoClick is reversible by default. Deleting an app, database, bucket, dict, queue, repo, volume, inbox or agent doesn't destroy it: it goes to the trash, where it stays restorable for 30 days and is then purged. A write that can be taken back records how, so its operation can be undone, and a dict, a bucket's files, a secret or a setting keeps what it held before. Only what truly can't be undone (purging the trash, money leaving the account, a message reaching a person) waits for a person when an agent does it.
The trash
delete() on a trashable resource returns its TrashItem:
import noclick
item = noclick.Database.from_name("orders").delete()
print(item.kind, item.name, item.purge_after) # database orders 2026-11-14 ...
item.restore() # back under its name, every table and row with itWhile it's in the trash:
- it's gone from every list and read, and its name is free for something new;
- what it holds is kept, and its storage is billed at the storage rate as before (a database's connection URLs stop working, and nothing reads or writes it; an app serves nothing, and what it keeps, its releases and build outputs, is billed as they were);
- restoring it is free.
| Kind | In the trash | Restoring brings back |
|---|---|---|
| App | offline: its releases, deployments, settings, secrets, bindings and schedules, and the domains it was served at | the app, served again from the same release with no build, at its address and domains, its schedules firing (not while its repo is in the trash) |
| Database | its tables and rows, and its branches deleted before it | the database under its name (a branch only beside its source) |
| Bucket | its files | the bucket and every file |
| Dict | its keys | the dict and its keys, expiries kept |
| Queue | its messages | the queue and its messages |
| Repo | its history | the repo, every branch and commit |
| Volume | its files | the volume and its files |
| Inbox | its mail; its address takes no mail | the inbox at its address, taken back from an inbox made there since once that one is deleted (while it's live, the restore is refused with its id as existing_id) |
| Agent | its sub-agents, conversations and memory | the agent and its sub-agents; wake-ups and jobs that ended stay ended |
List what's there, newest first, and put something back or purge it now:
for item in noclick.TrashItem.list(kind="bucket"):
print(item.name, item.trashed_at, item.purge_after)
stale = noclick.TrashItem.list(kind="volume")
for item in stale:
item.purge() # gone for good: this one can't be undoneRestoring is refused with noclick.Conflict when something new has taken the name since: rename or delete that one first, then restore. An app's address is held for it against every other account; a new app of yours with its name can take it, and the old one's restore then says so. Purging a repo purges the apps in the trash that deploy it, which can't come back without it.
Undo an operation
A reversible write records an operation that knows how to take it back. undo() runs that once:
seen = noclick.Dict.from_name("processed-orders")
cleared = seen.clear()
cleared.undo() # every key it removed that nothing has written since is back
item = noclick.Queue.from_name("invoices").delete()
noclick.Operation.from_id(item.operation_id).undo() # the same as item.restore()| Write | Its undo |
|---|---|
| Deleting a trashable resource | restores it from the trash |
| Clearing a dict | puts back the keys it removed, leaving newer writes as they are |
| A database migration | restores the database to the moment before it ran (bookmark on the result) |
| Restoring a SQLite database to a moment | restores it to the moment before the restore |
| Promoting a deployment | restores each database the app binds to the moment before production switched, and puts the release before back in production |
Every promote bookmarks the app's databases just before production switches, so a release that wrote bad data comes back out with its data:
app = noclick.Project.from_name("booking")
promoted = app.deployments(branch="new-checkout")[0].promote()
promoted.wait()
# ... it charged the wrong prices ...
promoted.undo() # the orders table as it was, and the previous release live againA database keeps 30 days of history on SQLite and 7 on Postgres, so a promote can be undone that long after.
An operation's undoable says whether undo() can take it back now. Undoing twice, or undoing what reached the world (an email sent, a call placed, a payment), raises noclick.Conflict. Over HTTP it's POST /v1/operations/{id}/undo.
A dict's history
Every write to a dict is logged for 30 days with the value it replaced, so a single key can be put back:
prefs = noclick.Dict.from_name("prefs")
for change in prefs.history("theme"):
print(change.at, change.op, change.previous) # changed, deleted or created
prefs.restore_change(prefs.history("theme")[0]) # the value it had before that writeA key changed again within a minute of its last entry adds none, so a busy counter keeps one restore point a minute.
A file's earlier versions
A bucket keeps what a file held before each write through NoClick replaced or removed it, for 30 days:
bucket = noclick.Bucket.from_name("uploads")
for version in bucket.versions("menu.pdf"):
print(version.replaced_at, version.op, version.size) # changed or deleted
bucket.restore_version("menu.pdf", bucket.versions("menu.pdf")[0])Versions are kept in the bucket itself, copied in place and deleted by its own lifecycle when their days are up, so they're stored (and billed) like its files but never listed as files or served at a public URL. An upload through a signed link or S3 credentials goes straight to the store and keeps none.
Earlier values of secrets and settings
A secret keeps the values it held before each change for 30 days, sealed: its versions say when each was replaced and which keys it held, never a value, and restoring one puts the sealed values back without showing them.
stripe = noclick.Secret.from_name("stripe")
print([v.keys for v in stripe.versions()])
stripe.restore_version(stripe.versions()[0])
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])
app.settings.restore("PUBLIC_BASE_URL", app.settings.versions("PUBLIC_BASE_URL")[0])An app's secrets and settings keep their deletions too, so a deleted one comes back the same way; the app sees it at its next deployment. Deleting a noclick.Secret is for good: its versions go with it.
What waits for a person
Because deletes, migrations and config changes can be undone, an agent acting through NoClick (an MCP connection, a hosted agent's noclick tool, the account's assistant) does them without asking. The policy can still hold any of them. What can't be undone keeps waiting for a person:
- purging the trash;
- deleting what has no trash: a secret, a phone number, a domain, a user, a key;
- money leaving the account, and consent to terms;
- a message to a real person outside the account.