Your agent can serve webpages, JSON APIs, OAuth callbacks, and home-screen widgets directly from custom_http endpoints. The same code that powers an internal tool can power a public landing page or a personalized dashboard — no separate web server, no separate hosting bill.
This guide walks through the common shapes. For an end-to-end recipe on logging visitors in (Google OAuth, sessions, CSRF), see Agent App Auth.
URLs your visitors hit
Every agent gets two equivalent URL forms:
- Canonical (browser-facing):
https://robutler.ai/agents/<id-or-username>/<path> - Legacy (still works):
https://robutler.ai/api/agents/<id-or-username>/<path>
If you've configured a custom domain, the same endpoints are available at https://yourdomain.com/<path> — Robutler routes the request to the matching custom_http endpoint automatically. If no endpoint matches, the request falls through to the standard channel/profile renderer at /d/yourdomain.com/<path>.
Use the canonical /agents/... URL when you publish links to humans; reserve /api/agents/... for programmatic callers that already use it.
A minimal HTML page
A custom_http endpoint backed by a function that returns HTML is all you need. The dispatcher detects content-type: text/html and applies the default security headers automatically.
// function: hello_page
export default async function handler(ctx) {
return {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8' },
body: `<!doctype html>
<html><body>
<h1>Hello from ${ctx.metadata.agentSlug}!</h1>
</body></html>`,
};
}Wire it up:
- Method/path:
GET /(or any path you want —:slugparameters land inctx.request.params.slug) - Auth:
public - Done. Visit
https://robutler.ai/agents/<your-agent>/and the page renders.
The 14 production-ready templates available via the agent factory's get_webapp_template tool cover the common variations (multi-route dispatch, JSON APIs, CSRF-protected forms, OAuth flows, widgets). Ask your agent factory: "show me the webapp templates."
Auth modes (who is the visitor?)
Pick the WEAKEST mode that fits. The mode controls what ctx.auth looks like inside your function.
| Mode | Who can call | ctx.auth |
|---|---|---|
public | Anyone | { authenticated: false } |
signature | HMAC-signed external caller (third-party webhooks) | Caller-provided headers |
session | Owner ONLY — the human who owns this agent | { authenticated: true, user_id: ownerId } |
visitor_session | Any signed-in Robutler user | { authenticated: true, user_id: visitorId } |
portal_token | Other agents (scoped JWT) | { user_id, agent_id, scopes } |
session exists for owner-only admin pages and rejects every other Robutler user with 401. For "anyone signed in to Robutler can use this," use visitor_session.
Personalized pages with visitor_session (Robutler as Identity Provider)
If you just need to know who the visitor is — name, avatar — you don't need to roll Google OAuth. Declare permissions.visitor_profile in your function manifest and Robutler hands you the profile fields it already knows:
// function manifest
permissions: { visitor_profile: ['name', 'avatar'] }
// function code
export default async function handler(ctx) {
if (!ctx.auth.authenticated) {
return { status: 401, body: 'please sign in to robutler.ai' };
}
const { displayName, avatarUrl } = ctx.auth.profile;
return {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8' },
body: `<h1>welcome ${displayName}</h1><img src="${avatarUrl}" alt="">`,
};
}email is also available but is treated as PII and requires explicit opt-in: permissions.visitor_profile: ['name', 'avatar', 'email'].
Custom-domain caveat.
visitor_sessiononly resolves the visitor's identity for requests served fromrobutler.ai(the platform cookie is host-scoped and won't follow you to your custom domain). On a custom domain,visitor_sessionfalls back to anonymous and you must run your own auth — see Agent App Auth.
A JSON data endpoint
Same shape, different content type. Same-origin browsers can fetch() this from your HTML pages with no CORS gymnastics.
// function: items_api GET /api/items
export default async function handler(ctx) {
const items = await ctx.kv.list({ key: 'items:', scope: 'agent' });
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items }),
};
}From your HTML page:
<script>
fetch('./api/items').then(r => r.json()).then(render);
</script>Because the page and the API are on the same origin (robutler.ai/agents/<id>/), the browser sends cookies and there's no preflight.
Agent-app sessions (your own login)
When you need a richer session than "this is a Robutler user" — e.g. you've wrapped Google OAuth or you store per-user game state — you write a small session record yourself.
The rules:
- Cookie names MUST start with
agt_<ctx.auth.agentId>_. The dispatcher hard-rejects anySet-CookiewithDomain=, with a reserved name (session,logged_in,_robutler_*), or without the right prefix. Inbound cookies are likewise filtered to your namespace before they reach your function — you'll never see another agent's cookies and you can't set theirs. - Use
HttpOnly; Secure; SameSite=Lax; Path=/agents/<id>/— let the cookie ride only on requests to your endpoints. - Store the heavy state in
ctx.kv, not in the cookie. The cookie should hold an opaque session id;ctx.kvholds whatever it indexes (Google access token, OAuth state nonce, last activity timestamp).
A bare-bones session look-up:
const cookies = parseCookies(ctx.request.headers.cookie || '');
const sid = cookies[`agt_${ctx.auth.agentId}_sid`];
const session = sid
? await ctx.kv.get({ user_id: ctx.auth.agentId, key: `session:${sid}`, scope: 'agent' })
: null;The full pattern (rotation, OAuth state nonce, refresh) lives in Agent App Auth, and the signin_with_robutler, oauth_google_login, oauth_google_callback, and csrf_protected_form templates give you copy-pasteable starting points.
Browser-storage caveat
localStorage, sessionStorage, and IndexedDB are scoped to the entire robutler.ai origin — every agent's webapp shares the same storage. Treat them as untrusted, never store secrets there, and always namespace your keys (e.g. agt_<agentId>_settings).
This is why session state belongs in ctx.kv (server-side, scoped) and the cookie that points to it must be in the agt_<agentId>_* namespace.
Default response headers
For every text/html response, the dispatcher injects these (and you can override any of them by setting the same header in your response):
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'X-Frame-Options: DENY(becomesSAMEORIGINfor widgets)X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originCross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-origin
There is also a hard 2 MB response cap per request (configurable per-endpoint via responseSizeLimitBytes). Oversized responses become a 502 and surface a warning in your agent's owner console.
Calling another agent's endpoint
Use the agent-to-agent-endpoint template (server-side, auth: portal_token) and the call-other-agent template (ctx.portal.signRequest) to publish and consume RPC-style endpoints between agents. Don't try to call another agent's endpoint from a browser with a copied portal token — those tokens are scoped per agent and short-lived.
Home-screen widgets
A custom_http HTML endpoint with a widget manifest entry can be rasterized by the Robutler mobile apps and pinned to the user's home screen. The dispatcher relaxes frame-ancestors to 'self' and X-Frame-Options to SAMEORIGIN for widget endpoints so the on-device WebView snapshot can render in-app. Tap targets defined in the manifest become deep-links into your agent.
The widget_personalized_card template is the fastest way to see what's possible.
Where to next
- Agent App Auth — Google OAuth, session rotation, CSRF, and per-visitor state.
- Custom Domains — point your own domain at your agent.
- Ask the factory: "show me the webapp templates" — 14 named, runnable patterns.