How to give an AI agent a password without putting it in the chat
A job of ours stopped this morning in front of a login page. It had been asked to pull timesheet exports out of SAP Fieldglass, it needed to sign in as the person who started it, and it had no password. So it did what a capable agent does with a gap: it went looking. It searched Gmail, opened three mail threads, searched Drive, listed two folders, and parked without a password.
Nothing it did was wrong. There was nowhere to put the answer.
Asking in the chat puts the password in the trace
The obvious fix is to ask. The agent says "what's your Fieldglass password", the person types it, and the job carries on.
Then the value is in the conversation. Which means it is in the transcript, in the call log, in the trace you hand a colleague to explain what the run did, and in the model's context on every following turn. Deleting the message does not remove the row, and nothing removes it from the context window of a turn that already ran.
This is not a Daslab problem. Any agent with tools hits it the first time a job needs a login that is not OAuth, and OAuth covers a small fraction of what an operation actually runs on. The rest is API keys, connection strings, service accounts, and web logins for systems that have no API at all.
MCP already forbids collecting a secret through the protocol
We went to write our own rule for this and found the rule written.
MCP has elicitation: a server can ask the client to collect something from the user mid-call. Form mode takes a JSON schema and gives you back typed fields, which looks exactly like what you want for a password. The spec says you may not use it for one:
Servers MUST NOT use form mode elicitation to request sensitive information such as passwords, API keys, access tokens, or payment credentials.
Servers MUST use URL mode for interactions involving such sensitive information.
URL mode arrived in the 2025-11-25 revision. It carries a message and a URL and nothing else. The client shows the person where it wants to send them, they consent, the browser opens, and whatever happens on that page happens between the person and your server.
The reasoning is worth reading twice, because it rules out the shortcut everyone reaches for. The problem is not that some clients are untrustworthy. It is that form data passes through the client and into the model's context. That is true of a client you wrote yourself, running the agent you wrote yourself. We were building exactly that when we read it, and it killed a design I liked: upgrade our own client to a richer form, collect the secret in band because we trust ourselves. Our own agent has a context too.
A forwarded link can put your password in someone else's workspace
We built the page, made the URL an unguessable one-time token, and thought we were done. The token is the capability, the way our share links work.
The spec names the attack on that. A malicious user triggers a credential request against their own workspace, forwards the link to a colleague, and the colleague fills it in. The credential is real, the person typing it meant well, and it lands in the attacker's workspace. Every stored-credential product has this shape, and a random token does nothing about it, because the token was always meant to be given away.
The rule the spec draws is that the server has to verify who opened the URL, not just that they hold it. So a request is fillable only by someone signed in who is already a member of the workspace the credential lands in. Forwarding to a colleague still works, which is half the reason the link exists: the person holding the SAP password is often not the person in the chat. Someone outside the workspace gets a 403.
The same request renders as a card, a page, or a protocol prompt
What came out of this is one row and several ways to look at it.
A credential request records which env vars or which provider account is being asked for, why, and which scene it lands in. Every workflow becomes a scene: its data, its tools, the agent that runs it, and the history of everything it did. The row holds the description of a form. It never holds a value.
In our own chat, a suspended job renders that request as a form card, and our chat is not an MCP client, so the values post straight from the browser to our API without passing through the protocol or the model at all. In Claude Code or any other MCP client, the same row comes back as a url-mode elicitation with a signed state blob, and when the client retries the call after the person has been sent off, that blob tells us which request to report on. In a client that declared nothing at all, the tool result carries the link as text, which has worked since the first version of the protocol.
We read which of those to send from the client's own capability declaration at initialize, which is also where we stopped hardcoding our protocol version and started negotiating it. What we deliberately do not read is clientInfo.name. It is self-reported, so a client that calls itself daslab is called daslab. It titles a job and labels the calls that job caused, and nothing consults it to decide what a caller may receive.
What the job gets back is a list of names
A filled request stores each secret as its own asset in the scene.
The names are enough to work with. A step can splice ${secrets.FIELDGLASS_PASSWORD} into a tool input, the real string reaches the tool, and a redactor replaces it with «secret:FIELDGLASS_PASSWORD» on the way to the call log, the trace and the model's next turn. The agent uses a credential it has never seen and cannot print. For the Fieldglass job that means the browser step types the password into the login form and the recorded input says the name.
An inline app cannot collect a secret, because it has no session
The obvious next move is MCP Apps, which became the first official MCP extension in January: a tool declares a ui:// resource, the host renders it in a sandboxed iframe, and the person gets a real form inside Claude or ChatGPT.
We checked whether the form could be ours. An app can reach a declared origin, so the network call is allowed. But it runs on a domain the host computes, with no same-origin server behind it, so it holds no session with us. It cannot establish who is filling the form, which is the exact check that stops the forwarded link. Making it work would mean shipping a session token through the host to get it into the iframe, which puts something worse in the channel than the link we were trying to avoid.
What's next is the same negotiation for the widgets
Reading the client's capabilities was the part of this worth keeping. Credentials are the one thing that will never travel further than a link, but everything else a scene draws is a candidate: a cell is authored HTML with a tool bridge, which is roughly what an MCP App is, and the handshake now tells us which clients could render one.
That is what we build next.