When a site you built with an assistant needs an API key, the assistant does not ask you for it, and there is no juudd tool that would accept it. The assistant names the secret, you paste the value into the panel yourself, and the site reads it as env.<NAME>. The platform stores the name and cannot read the value back.
That is the whole decision. The rest of this post is why the obvious tool was refused, where the value goes instead, and what we kept ourselves from building.
A site that takes payments had nowhere to put the key
A deployed site could hold exactly one binding, its database connection. A site that takes payments, sends email or calls anyone's API had nowhere to put the key except its own code, and deployed code is public.
That is not a small gap. It is the reason a whole class of site could not be built here. An assistant reading the tool list while it designs the app found nothing that would hold a payment key, so it told you to go somewhere else or to paste the key into a file. Both are the wrong answer for different reasons.
The obvious tool is the one we refused
The obvious shape is set_secret(site, name, value). It is short, and an assistant would find it at once. We did not build it.
A key in a tool argument is a key in the model's context, in whatever transcript your client keeps, and in every log between there and here. It does not matter which way the secret is travelling. The same sentence already kept the database owner's connection string out of every tool's result, and it applies just as well to a value going in.
So there are two tools, and neither takes a value.
request_secrettakes a site and a name, records that the name was asked for, and returns a link into the panel.list_secretssays which names exist and whether each has a value yet. Never the value.
They are two tools rather than one with a mode because a merged tool would carry the annotation of the half that writes onto the half that only reads. A client that confirms before every write would then stop to ask you about every lookup.
The value goes in where you are already signed in
The link lands on the site's page in the panel. You are already signed in there, and the act of handing over a credential is one a person should be holding rather than a model. You paste the value, and the panel writes it onto the site by republishing the version that is already live: the same files, unchanged, with one more binding.
From then on, every deploy carries the value forward without anyone reading it. The platform keeps the name of each secret and whether a value has been written, and nothing else. The record has no place to put a value, and it must never gain one.
Three small rules shape what you can paste.
- A name is uppercase letters, digits and underscores, starts with a letter, and is at most 64 characters, like
STRIPE_SECRET_KEY. That is how every key is spelled and how a model writes it without being told, and it leaves no case where two spellings name different bindings. - A value is at most 4096 characters. A key is rarely a tenth of that, and the limit exists so the refusal is our sentence rather than a transport's.
- Surrounding whitespace is trimmed first. A value copied out of a dashboard arrives with a trailing newline more often than not, and a key with a newline on the end fails in front of visitors with nothing saying why.
Three refusals, and which way each one errs
DATABASE_URL is refused as a name. Anyone reaching for it here has a database problem, so the sentence names the tools that own that binding instead of telling them to try another spelling.
A site with no stored version cannot take a value, because the value reaches the site by republishing one. We could have held the value until the first deploy. Holding it means building the store this design exists to avoid, so the refusal teaches the order instead: deploy first, with the code that reads env.<NAME> already in it, and set the value afterwards. request_secret says the same thing one step earlier, so you hear it before the trip to the panel rather than at the end of it.
A site that is not being served cannot take a value either. Writing one would republish a script, which would put a stopped site back on the air as a side effect of someone pasting a key.
Loud beats silent
The record that a name was set is written before the publish, and that looks backwards. If the row went second and failed, the value would sit on the site with nothing knowing to carry it, and the next deploy would drop it quietly. The site would lose its key and nothing in the upload would look wrong. Written first, a failed publish leaves the next deploy asking to carry a value that is not there, which fails at once and says so, and the repair is pasting the value again.
A name that was declared and never filled is never carried. A redeclaration leaves an already-set name alone, for the same reason: clearing it would tell every later publish to stop carrying a value that is on the site.
What is not decided
- Rotation and removal are not built, and their wording is not designed. A name that is already set is replaced by setting it again.
- There is no cap on the number of names a site may declare.
- The reply to a deploy does not yet say which secrets are still waiting for a value.
- Until the value arrives, a route that reads it fails in front of visitors.
site_errorsreports that a route failed and when, not that a secret is the reason.
This path was built and tested against fakes. We have not yet recorded a production site carrying a set secret through a later deploy, so this post claims only that the value is written by a republish and carried forward.
The rule, once
A credential is never typed into a conversation: the assistant declares the name, you hand over the value in the panel, and the platform keeps the name and never the value. The tools and their limits are listed in the docs.