This guide is the focused recipe for wiring up logins on a webpage your agent serves. The general background — URL shapes, security headers, the visitor_session mode that doesn't need any of this — lives in Serving Webpages. Read that first if you haven't.
You only need a custom auth flow when:
- Your visitors are not Robutler users (so
visitor_sessionwon't work), or - You need Google API access on behalf of the visitor (Calendar, Drive, Gmail), or
- You're publishing your webapp on a custom domain where the platform cookie can't reach.
For "I just want to know who this Robutler user is," skip everything below and use auth: visitor_session + permissions.visitor_profile.
The single-webapp pattern
Every flow on this page assumes one function pinned to one path prefix on your agent — the webapp function. It dispatches its own routes internally. This keeps every cookie, every redirect URI, and every ctx.kv key in one consistent namespace and avoids cross-function session-state surprises.
// custom_http endpoint: GET|POST /webapp/* -> webapp_handler
export default async function handler(ctx) {
const url = new URL(ctx.request.url);
const path = url.pathname.replace(/^.*\/webapp/, '') || '/';
if (path === '/login') return await login(ctx);
if (path === '/oauth/start') return await oauthStart(ctx);
if (path === '/oauth/callback') return await oauthCallback(ctx);
if (path === '/logout') return await logout(ctx);
return await page(ctx); // default = render the app
}The factory templates multi_route_dispatch, oauth_google_login, oauth_google_callback, csrf_protected_form, and logout give you copy-pasteable bodies for every branch.
Cookie rules (non-negotiable)
The dispatcher enforces these. If you set a cookie that violates any of them, the response is rejected with a 502 and an INVALID_SET_COOKIE warning surfaces in your owner console.
- Name MUST start with
agt_<ctx.auth.agentId>_. Usectx.auth.agentId(the canonical UUID), not the username — that's the namespace the dispatcher checks. - No
Domain=attribute. Cookies are host-locked torobutler.ai(or your custom domain). - Reserved names are blocked outright:
session,logged_in, anything starting with_robutler_. - Recommended attributes:
HttpOnly; Secure; SameSite=Lax; Path=/agents/<canonicalId>/. The narrowPathkeeps the cookie off other agents' endpoints.
const cookieName = `agt_${ctx.auth.agentId}_sid`;
const cookieValue = encodeURIComponent(sid);
const cookiePath = `/agents/${ctx.auth.agentId}/`;
return {
status: 302,
headers: {
location: '/agents/' + ctx.auth.agentId + '/webapp/',
'set-cookie': `${cookieName}=${cookieValue}; HttpOnly; Secure; SameSite=Lax; Path=${cookiePath}; Max-Age=${60 * 60 * 8}`,
},
};Inbound cookies are mirror-filtered — your function only ever sees cookies in your agt_<agentId>_* namespace. You cannot read another agent's cookies and they cannot read yours, even on the shared robutler.ai origin.
ctx.kv key shapes
Cookies hold the opaque session id. ctx.kv holds everything that session id maps to. Two patterns cover almost every case:
| Use case | Storage call | Key shape |
|---|---|---|
| Per-visitor session record | ctx.kv.put({ user_id: ctx.auth.userId, key: 'session:<sid>', value: {...}, scope: 'agent' }) | scoped to the visitor |
| Agent-wide config / shared state | ctx.kv.put({ user_id: ctx.auth.agentId, key: 'config:<name>', value: {...}, scope: 'agent' }) | scoped to the agent itself |
Required permission in the function manifest:
permissions: {
kv: { self: 'rw', visitor: 'rw' },
}self covers user_id === ctx.auth.agentId (agent-wide). visitor covers user_id === ctx.auth.userId (per-visitor — only writable while the request has an authenticated ctx.auth.userId).
Storing data under any other user_id returns PERMISSION_DENIED.
Google OAuth (web, server-side)
You're after a refresh token so your agent can call Google APIs on behalf of the user even when they're offline. The two halves of this flow live in oauth_google_login and oauth_google_callback.
One-time setup
- In Google Cloud Console, create an OAuth 2.0 Client ID (Web application).
- Authorized redirect URI:
https://robutler.ai/agents/<your-agent>/webapp/oauth/callback(and the equivalent on your custom domain if you have one). - Store the client id and client secret as agent secrets via the factory (
set_secret google_client_id ...,set_secret google_client_secret ...).
Step 1 — Start the flow
async function oauthStart(ctx) {
const state = crypto.randomUUID();
await ctx.kv.put({
user_id: ctx.auth.user_id ?? 'anon:' + state,
key: 'oauth:state:' + state,
value: { createdAt: Date.now() },
scope: 'agent',
ttlSeconds: 600,
});
const params = new URLSearchParams({
client_id: await ctx.secrets.get('google_client_id'),
redirect_uri: `https://robutler.ai/agents/${ctx.metadata.agentSlug}/webapp/oauth/callback`,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: 'openid email https://www.googleapis.com/auth/calendar.readonly',
state,
});
return {
status: 302,
headers: { location: 'https://accounts.google.com/o/oauth2/v2/auth?' + params },
};
}The state nonce defeats CSRF on the callback. Always store-and-compare; never accept a callback whose state you didn't mint.
Step 2 — Handle the callback
async function oauthCallback(ctx) {
const url = new URL(ctx.request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const stateRecord = await ctx.kv.get({
user_id: ctx.auth.user_id ?? 'anon:' + state,
key: 'oauth:state:' + state,
scope: 'agent',
});
if (!stateRecord) return { status: 400, body: 'invalid state' };
await ctx.kv.delete({
user_id: ctx.auth.user_id ?? 'anon:' + state,
key: 'oauth:state:' + state,
scope: 'agent',
});
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: await ctx.secrets.get('google_client_id'),
client_secret: await ctx.secrets.get('google_client_secret'),
redirect_uri: `https://robutler.ai/agents/${ctx.metadata.agentSlug}/webapp/oauth/callback`,
grant_type: 'authorization_code',
}),
});
const tokens = await tokenRes.json();
// tokens = { access_token, refresh_token, expires_in, id_token, ... }
// 1. Identify the user from id_token (or by calling /userinfo).
const idClaims = JSON.parse(atob(tokens.id_token.split('.')[1]));
const userKey = 'google:' + idClaims.sub;
// 2. Mint your own session id and store both records keyed under the visitor.
const sid = crypto.randomUUID();
await ctx.kv.put({
user_id: userKey,
key: 'tokens',
value: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + tokens.expires_in * 1000,
},
scope: 'agent',
});
await ctx.kv.put({
user_id: userKey,
key: 'session:' + sid,
value: { userKey, createdAt: Date.now() },
scope: 'agent',
ttlSeconds: 60 * 60 * 8,
});
return {
status: 302,
headers: {
location: `/agents/${ctx.metadata.agentSlug}/webapp/`,
'set-cookie': `agt_${ctx.auth.agentId}_sid=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/agents/${ctx.auth.agentId}/; Max-Age=${60 * 60 * 8}`,
},
};
}Refresh-token rotation. When you refresh, persist the new
refresh_tokenif Google sends one back. If a refresh fails withinvalid_grant, the user revoked you — clear the tokens and force re-consent.
CSRF for state-changing forms
Same-origin fetch() from your own page is normally fine because the browser sends your cookies and won't share them cross-origin (you set SameSite=Lax). But classic <form method="post"> submissions are not protected by SameSite=Lax in the case where the form lives on another origin. For any form that mutates state, use a per-session token:
- Issue a token on first GET, store under the session:
ctx.kv.put({ user_id: visitor, key: 'csrf:<sid>', value: token, scope: 'agent', ttlSeconds: 3600 }). - Render it as a hidden field and a header on JS-driven submits.
- On POST, look it up; reject mismatches with 403; rotate after each successful state change.
The csrf_protected_form template is the full pattern.
Logout
Three things, in order:
- Delete the session record:
ctx.kv.delete({ user_id: visitor, key: 'session:' + sid, scope: 'agent' }). - Send the same cookie back with
Max-Age=0and the samePath=,Secure,HttpOnly,SameSite=Laxattributes as on issue (browsers only honor the deletion if the attributes match). - Redirect to your public landing page (or
/login).
The logout template captures all three.
Session rotation
Every login or privilege change should mint a new session id (and delete the old one). Same goes for any anonymous → authenticated transition. This bounds the blast radius of a leaked id and is the single highest-value habit in agent-side auth.
const newSid = crypto.randomUUID();
await ctx.kv.put({ user_id, key: 'session:' + newSid, value: sessionRecord, scope: 'agent' });
await ctx.kv.delete({ user_id, key: 'session:' + oldSid, scope: 'agent' });
// then issue the cookie with the new sidCustom-domain note
If your agent is on https://yourdomain.com, the platform cookie that visitor_session relies on never reaches your function (cookies are host-scoped). All the patterns above work unchanged on a custom domain because you're issuing your own host-scoped cookie in your own agt_<agentId>_* namespace — but you cannot fall back to "let me ask Robutler who this is." Plan for full self-hosted auth on custom domains, or redirect users back to https://robutler.ai/agents/<slug>/login (the signin_with_robutler template handles return-URL sanitization) and bounce them back to your custom domain after the session record is set.
Cookies set by your
robutler.aiagent endpoints do not travel to your custom domain. If you want a unified session across both, store the session record inctx.kvkeyed by something the visitor can carry across (e.g. a short-lived signed handoff token in the URL fragment).
Where to next
- Serving Webpages — the broader picture (URLs, headers, widgets, JSON APIs).
- Custom Domains — DNS + SSL setup.
- Ask the factory for a template:
oauth_google_login,oauth_google_callback— the full OAuth pair.csrf_protected_form— POST forms with rotating tokens.kv_visitor_state— per-visitor preferences.signin_with_robutler— bounce visitors to Robutler login with a sanitized return URL.logout— clear the session and the cookie.