How to let an AI agent draft but never publish
Give the model tools that read and prepare, and leave the irreversible tool out of its tool list entirely, so that only a person pressing a button in your own interface can call it. Bind that press to a one-time ticket and a hash of the exact content the person saw, then add a short undo window, a separate consent scope, per-account limits and an audit trail.
- Updated
- Reading
- 5 min
- The need
- Let AI agents prepare posts, messages or other actions while a person approves the irreversible step, in a way that prompt injection alone can't bypass.
- Plain text
- /guides/ai-agent-draft-not-publish.md
#The rule, and where it lives
An agent may write. A person publishes.
Where the rule is enforced matters. A system prompt line such as "always ask before posting" is text, and other text can outvote it: an email, a web page, an issue the agent reads. A tool that is not in the model's tool list can't be called, whatever the model reads. The pattern works in any agent framework; MCP Apps gives it a standard shape.
#Why a confirmation step alone isn't enough
- Prompt injection. Invariant Labs showed an agent using the GitHub MCP server being steered by a malicious issue into leaking data from private repositories. Simon Willison calls the dangerous mix the "lethal trifecta": private data, untrusted content and a way to communicate externally.
- Confirmations the model can write. An "Are you sure?" that the model prints is not a human decision.
- Hints are not locks. The MCP specification says clients must treat tool annotations such as
destructiveHintas untrusted unless they come from trusted servers.
The same specification says a human should always be able to deny tool calls. The pattern below makes that human the only one who can make the final call.
#The pattern
1. Split the tools by effect
| Tool | Visible to | Effect |
|---|---|---|
get_*, list_* |
model and UI | none |
prepare_post |
model and UI | stores a draft, shows a preview |
publish_post |
UI only | publishes |
undo_post |
UI only | removes the post within the undo window |
2. Hide the irreversible tool from the model
MCP Apps, an extension to the Model Context Protocol, lets a tool's result appear as an interactive view inside a host such as Claude or ChatGPT, and lets a server declare who may call each tool:
{
"name": "publish_post",
"annotations": { "destructiveHint": true },
"_meta": {
"ui": {
"resourceUri": "ui://example/preview.html",
"visibility": ["app"]
}
}
}
Under the MCP Apps specification, the host must not list a tool for the agent when its visibility lacks "model", and must reject calls from the view to a tool whose visibility lacks "app". But the host enforces this, not your server: both kinds of call arrive over the same connection, so your server can't tell a press from a model call.
3. Bind the press to the exact content
When the model calls prepare_post, the server stores the text, hashes the account, target and text with SHA-256, and issues a one-time ticket that expires in minutes. Store only a hash of the ticket.
Send the ticket to the view in the result's _meta, never in content or structuredContent. The MCP Apps specification keeps structuredContent and _meta out of the model's context, but OpenAI's Apps SDK documents that ChatGPT shows structuredContent to the model while hiding _meta. Assume the model can read anything outside _meta.
On Publish, the view sends only the draft ID, the hash and the ticket. The server publishes the text it stored:
// publish_post, callable from the view only
const d = await drafts.get(input.draft);
if (!d || d.account !== auth.account || d.state !== "prepared" || d.expiresAt < Date.now()) throw deny();
if (!safeEqual(sha256(input.ticket), d.ticketHash) || input.sha !== d.bodySha) throw deny();
await publishOnce(d); // marks the draft used and inserts its stored text, in one transaction
The model can't swap the text after the person has read it, and a ticket works once.
4. Send risky drafts to your own site
Send an account's first agent post, and anything with links or mentions, to your own compose screen with the text filled in. The person publishes there through every normal check, which also covers a host that misbehaves.
5. Rebuild the checks your normal path runs
A publish function with elevated rights skips what the person's own path enforces: row-level permissions, rate limits, length rules, membership. Run each check again inside the function and test that it fails when it should.
6. Add undo, scopes, limits and a record
- Undo. A short window, such as ten minutes, catches mistakes. Undo should reverse every side effect exactly as a normal delete would.
- A separate scope. Make publishing its own OAuth scope, off by default and never required, so a person who declines keeps the rest of the connection. The consent page lists what the client can and cannot do.
- Short, bound access tokens. The MCP authorization specification requires each access token to be issued for one server, through RFC 8707 resource indicators. Rotate refresh tokens, a protection RFC 9700 gives for public clients.
- Limits and an audit trail. Cap agent publishing per account per day. Record who published, through which client, with the content hash, and label the item with its origin.
#Pitfalls
- Hiding the publish tool by instruction instead of leaving it out of the tool list.
- Putting the ticket anywhere the model can read it.
- Publishing text from the call instead of the stored draft.
- Tickets that live long or work twice.
#Questions
Doesn't the host already ask before each tool call?
Many hosts do, and you should keep that. But people approve such prompts out of habit, and a prompt often shows raw arguments rather than the post as it will appear. Your card shows the post.
What if a host ignores visibility?
The model may then see publish_post, but it still has no ticket, and risky drafts still go to your own site. That is why the layers are separate.
Can injected content still cause harm?
It can shape the draft, which is why the person reads the exact text before pressing. Treat every draft as untrusted until then.
#How Upvera does it
Upvera runs its own OAuth 2.1 authorization server for MCP at mcp.upvera.co. Access tokens last one hour and are bound to the server; refresh tokens last 30 days and rotate on every use. Publishing is an optional permission, off unless the person ticks it at connect time.
Upvera marks publish_post and undo_post as visible only to the preview card, so a host that follows MCP Apps never lists them for the model. A post from the card goes out only when the person presses Publish, and the press carries a one-time ticket and a hash of the exact text. Undo stays open for 10 minutes, and card posts are limited to three per account per day. A first post, or one with a link or a mention, is confirmed on Upvera's own compose screen. Publishing from the card is rolling out. More in the Upvera ID report and An AI that never sees the number.
#Sources
- Model Context Protocol
- MCP specification, tools
- MCP specification, authorization
- MCP Apps extension (ext-apps)
- MCP Apps specification, 2026-01-26
- OpenAI Apps SDK reference, tool results
- Invariant Labs, GitHub MCP exploited
- Simon Willison, the lethal trifecta
- OWASP, LLM01 prompt injection
- OWASP, LLM06 excessive agency
- RFC 8707, resource indicators for OAuth 2.0
- RFC 9700, OAuth 2.0 security best current practice
- OAuth 2.1 draft
- Upvera MCP server
- Security at Upvera for developers