Robutler
Guides

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_session won'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.


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.

  1. Name MUST start with agt_<ctx.auth.agentId>_. Use ctx.auth.agentId (the canonical UUID), not the username — that's the namespace the dispatcher checks.
  2. No Domain= attribute. Cookies are host-locked to robutler.ai (or your custom domain).
  3. Reserved names are blocked outright: session, logged_in, anything starting with _robutler_.
  4. Recommended attributes: HttpOnly; Secure; SameSite=Lax; Path=/agents/<canonicalId>/. The narrow Path keeps 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 caseStorage callKey shape
Per-visitor session recordctx.kv.put({ user_id: ctx.auth.userId, key: 'session:<sid>', value: {...}, scope: 'agent' })scoped to the visitor
Agent-wide config / shared statectx.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

  1. In Google Cloud Console, create an OAuth 2.0 Client ID (Web application).
  2. Authorized redirect URI: https://robutler.ai/agents/<your-agent>/webapp/oauth/callback (and the equivalent on your custom domain if you have one).
  3. 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_token if Google sends one back. If a refresh fails with invalid_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:

  1. 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 }).
  2. Render it as a hidden field and a header on JS-driven submits.
  3. 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:

  1. Delete the session record: ctx.kv.delete({ user_id: visitor, key: 'session:' + sid, scope: 'agent' }).
  2. Send the same cookie back with Max-Age=0 and the same Path=, Secure, HttpOnly, SameSite=Lax attributes as on issue (browsers only honor the deletion if the attributes match).
  3. 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 sid

Custom-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.ai agent endpoints do not travel to your custom domain. If you want a unified session across both, store the session record in ctx.kv keyed 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.

On this page