Gate an action behind sign-in
Let anyone use your app, ask for an account only when one action needs it, and never throw away what the visitor already typed.
Let people try first, then ask
Some apps should work for anyone who opens the link, with one or two actions saved for people with an account: save a high score, post a comment, keep a personal list. The visitor should not hit a wall on page load. They should be able to look around, and only get asked to sign in the moment they try the thing that needs it.
Charming’s runtime carries this all the way through: it recognises a refusal that signing in would fix, the sign-in happens in a popup without leaving the app, and the action the visitor already took runs again with what they already typed. Nobody has to notice they weren’t signed in until the one moment it mattered, and nobody loses their work when they find out.
Make sure signing in actually helps
Before you write any client code, check that the account a visitor signs into is one the app already lets in. Turn on “Everyone can view, signed-in people can edit” in the app’s Access settings, or invite the specific people you want as collaborator or end-user with share_app. Either way, see Privacy and sharing for what each level grants. Skip this and a visitor who signs in gets a plain forbidden refusal next, not the account they were promised — signing in fixed nothing because nobody had opened the app to them.
Wrap the action in withUser
For an action a visitor might already be able to run, or might need an account for, wrap it in window.charming.withUser(action) instead of asking them to sign in up front. It runs the action, signs the visitor in only if the action turns out to need it, and runs the exact same action again, payload and all, because that payload lives in the closure you wrote, not in a form Charming has to remember for you:
try {
const saved = await charming.withUser(() => api.saveScore({ score }));
if (saved === null) status.textContent = 'Not saved. Sign in when you are ready.';
} catch (err) {
if (err.name !== 'CharmingLoginError' || err.reason !== 'popup-blocked') throw err;
offerSignInButton(); // written out under "When the popup gets blocked anyway"
}
Wrap the call, not the click handler. withUser re-invokes your closure verbatim, so anything else you put inside it runs twice: an id generated there is generated twice, an optimistic row is appended twice, an analytics event fires twice. Keep those outside and hand withUser the one call that can be refused.
withUser resolves null when the visitor closes the sign-in window — that is a visitor’s choice, so say so and leave their work alone. It also resolves with whatever the action returned, and charming.api() returns the op’s own value, so an op that answers null on success reports “Not saved” after saving. Have the wrapped op return something else: the saved row, an id, true.
Any other failure rethrows unchanged; an account was never going to fix a quota error or a bad input, so withUser does not pretend otherwise.
Leave the button alive for a visitor who has not signed in
A visitor with no account reads charming.viewer.can('saveScore') === false on an app that is open to signed-in editors — they hold read access, not run access, until they sign in. Disable the button on that answer and the visitor never clicks, withUser never runs, and the sign-in you built is dead code.
So split the two causes before you disable anything:
const { viewer, user, login } = charming;
saveButton.disabled = !viewer.can('saveScore') && (user !== null || !login.available);
A false from can(op) while charming.user is null and charming.login.available is true means “ask them to sign in”, not “show a read-only app”. Word the button for it if you like (“Save (sign in)”), but leave it clickable. Disable it only for a caller who is signed in and still refused — a real read-only role — or where no sign-in can run at all. The same split applies to a forbidden error you catch by hand: raised while charming.user is null, it is the sign-in case, not the calm read-only case.
Call login() yourself for a button that’s always gated
When you already know an action needs an account, before the visitor even clicks it, call window.charming.login() straight from that click handler instead of routing it through withUser. A popup opened inside the click that asked for it survives nearly every block; one opened later, from inside an await, is exactly what a browser like Safari or Firefox refuses. That timing is also why withUser’s own retry can hit the block: its login() call happens after the first attempt has already failed and returned, one tick removed from the original click.
Nearly every block, not every one. A visitor who turned popups off by hand blocks the click-driven popup too, and it rejects the same way. Handle that on the sign-in button as well, not only around withUser.
When the popup gets blocked anyway
A blocked popup is not a cancel. window.open hands back nothing, so login() rejects at once with a CharmingLoginError carrying reason: 'popup-blocked', instead of leaving your app waiting on a window that will never close. Miss this and the visitor sees “Saving…” for as long as your code waits before giving up, then a failure with no obvious next step — the app looks stuck, not gated.
The recovery has two steps: a button whose own click can open the popup, and a plain link for the visitor whose browser blocks that click too.
function offerSignInButton() {
status.textContent = 'Sign in to save this.';
signInButton.hidden = false;
signInButton.onclick = async () => {
try {
const user = await charming.login();
if (user) await api.saveScore({ score });
} catch (err) {
if (err.name !== 'CharmingLoginError') throw err;
// Popups are off for good on this browser. A link the visitor opens
// themselves is the one route left.
signInLink.href = charming.login.canonicalUrl;
signInLink.textContent = 'Open Charming to sign in';
signInLink.hidden = false;
signInButton.hidden = true;
}
};
}
Give that fallback handler a real body and its own catch. Left as a bare comment, the one path meant to recover from a blocked popup is where the next unhandled rejection lands.
In an embed, check login.available before you offer the action
Inside a chat host that is not the app’s own capability sub-origin, charming.login.available is false. withUser opens no popup there — it rethrows the refusal the action raised. An app whose only catch looks for popup-blocked rethrows that again, so the visitor gets an unhandled rejection and a “Saving…” that never moves.
Read the flag before you offer the gated action at all, and offer the link instead:
if (!charming.user && !charming.login.available) {
saveButton.hidden = true;
signInLink.href = charming.login.canonicalUrl;
signInLink.textContent = 'Open Charming to sign in and save';
signInLink.hidden = false;
}
This is the same charming.login.available the design rules already tell you to read before rendering a sign-in button. charming.login.unavailableReason says why it is unavailable, and charming.login.canonicalUrl is the app’s top-level home, where sign-in works.
Which shape fits
Wrap an action in withUser when a visitor might already be signed in, or might not need to be: the common case for a save, a post, a vote. Call login() directly from a dedicated sign-in button when you already know the account is required before the visitor acts. Do both in the same app if it needs both — a gated save wrapped in withUser, and a plain “Sign in” button beside it that calls login() on its own click.
Technical
Copy this prompt for your agent
Gate one action in my Charming app behind sign-in: let anyone open
the app, but ask for an account only when they try <the action>.
Read https://charm.ing/docs/guides/gate-an-action-behind-sign-in.md
first, then build it so a blocked sign-in popup shows a button
instead of leaving the app looking stuck.
How an agent performs this job
No MCP tool call replaces this client-side half: write window.charming.withUser(action) (or a direct window.charming.login() for a standing sign-in button) into ui, and catch CharmingLoginError with reason: 'popup-blocked' rather than letting it become an unhandled rejection. Read charming.user before disabling a gated control, and charming.login.available before offering one at all. For the access half, either ask the owner to turn on signed-in write access in the app’s Access settings, or call share_app with role: 'end-user' or 'collaborator' for named people. See the full build reference for the exact login() / withUser() return shapes.
The contract
withUser retries around two error kinds, and an anonymous visitor meets them in this order:
forbidden— the runtime’s own viewer gate, raised in the page before any request goes out. An anonymous visitor on an app open to signed-in editors hits this one first, every time: they hold read access, so the client-side gate refuses the write locally.sign_in_required(401) — the server’s answer on a claimed app when a caller with no proven identity reaches a gated route. Distinct fromunauthorized, the unclaimed-app edit-token case, which signing in does not fix.
Branch on both yourself only when you call the API directly instead of through withUser. Branching on sign_in_required alone misses the case the whole pattern exists for.
login() rejects with a CharmingLoginError: reason: 'embedded-host' when a popup cannot open at all (inside a chat host), reason: 'popup-blocked' when the browser refused the window this time. When charming.login.available is false, withUser does not call login() at all — it rethrows the action’s original refusal untouched, so an embed needs the canonicalUrl link rather than a popup-blocked handler.
Related
- Privacy and sharing: the access levels a sign-in has to land inside to mean anything, and the same
withUsersnippet in its own context - Charming design guide: the sandbox rules for the UI code this pattern lives in
- Authentication: token scopes and recovery for agents calling the API directly, as opposed to a visitor signing in inside a rendered app
- Docs home