Skip to content

Auth-Gated UI

read as .md

ggui is a renderer, not an identity provider. Your app already knows who the user is. There are two separate auth concerns, and only the first is yours to wire:

  1. Authenticating your app → agent backend. Every request useMcpAppsChat makes (prompt POST, snapshot GET, the iframe → MCP tool-call relay) carries a bearer token your app supplies. The backend gates chat ownership on the principal that token identifies. This is the recipe below.
  2. OAuth consent for tools the agent calls. When a tool the agent calls needs the end-user to authorize a third-party service (Google, Slack), that consent flow is not ggui’s responsibility — it belongs to MCP’s OAuth standard, the hosting agent, and your client app. ggui only surfaces an optional signal. See OAuth tool-consent is the client’s job.

useMcpAppsChat takes two auth hooks:

  • getAuthToken() — called before every request; return the bearer token to send as Authorization: Bearer <token> (or undefined for none).
  • onUnauthenticated() — called on a 401; refresh/re-mint your token, return true to retry the request once, false to surface the error.

The ggui-basic-web sample uses these for a guest-token flow: it mints an anonymous token from the backend’s POST /auth/guest mount, caches it, and re-mints on 401. Swap that mint for your own user-session token and the same wiring gates renders behind a signed-in user:

import { useCallback, useRef } from "react";
import { useMcpAppsChat } from "@ggui-ai/react/chat-helpers";
function Chat({ agentEndpoint }: { agentEndpoint: string }) {
const { user, getAccessToken, refreshSession } = useAuth(); // your auth provider
// Read the current token per-request (kept in a ref so the callback
// always sees the latest after a refresh).
const tokenRef = useRef<string | undefined>(getAccessToken());
const getAuthToken = useCallback(() => tokenRef.current, []);
// 401 → refresh once, then retry. Return false to give up.
const onUnauthenticated = useCallback(async () => {
const fresh = await refreshSession();
if (!fresh) return false;
tokenRef.current = fresh;
return true;
}, [refreshSession]);
const { entries, sessions, send, handleAppMessage } = useMcpAppsChat({
chatEndpoint: `${agentEndpoint}/agent`,
snapshotEndpoint: `${agentEndpoint}/agent`,
getAuthToken,
onUnauthenticated,
});
// render `entries` + mount `sessions` with <AppRenderer> as usual
}

The token rides every hook request automatically. The backend authenticates it and scopes the conversation (its chatId snapshot + resume state) to that principal — clear the session and you’re a different principal with different chats.

Don’t open the chat until your provider says the user is signed in — a plain conditional render, exactly how the sample guards on guestTokenReady:

function App({ agentEndpoint }: { agentEndpoint: string }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <p>Loading…</p>;
if (!isAuthenticated) return <LoginPage />;
return <Chat agentEndpoint={agentEndpoint} />;
}

When the iframe dispatches a tool call, your host relays it to the agent backend’s POST /agent (kind: "tool-call") inside <AppRenderer onCallTool>. Send the same bearer token on that relay so the MCP call runs as the signed-in user — the sample’s onCallTool reads getAuthToken() and sets Authorization: Bearer <token> on the relay fetch. Don’t trust anything the iframe puts in the payload as identity; the token on the relay is the credential.

A different situation: the agent wants to call a tool that needs the end-user to authorize a third-party service first (read their Google Calendar, post to Slack). That consent flow is deliberately outside the ggui protocol. It is owned by:

  • MCP’s OAuth standard — the spec defines how an MCP server advertises that a tool needs authorization and how the consent/token exchange happens.
  • The hosting agent (e.g. @anthropic-ai/claude-agent-sdk) — follows that spec to handle tool authorization.
  • Your end-user client app — the surface actually talking to the agent backend pops its own consent UI and drives the redirect. Consent is a client decision, not something ggui’s runtime or protocol renders.
  • Guuey’s MCP proxy — captures an OAuth credential once so multiple agents can reuse it (coming soon — part of the hosted Guuey platform, not the open ggui protocol).

So ggui itself renders no consent screen. What it does provide is an optional helper signal: when the server emits a system frame with action: "auth_required", the iframe-runtime projects it to a typed AuthRequiredEvent on the ggui:observe postMessage channel ({ type: "ggui:observe", event }). A host that wants to surface a consent prompt can listen for it and open authUrl in a popup — but rendering that prompt, and the OAuth exchange itself, are your client’s job:

import type { AuthRequiredEvent } from "@ggui-ai/react";
// AuthRequiredEvent shape (kind: "auth-required"):
// provider — canonical service id ("google", "slack")
// authUrl — URL to open to start the OAuth consent flow
// displayName? — human-readable service name
// scopes? — requested OAuth scopes
// message? — why access is needed

It’s a projection of the protocol’s SystemPayload, not a new ggui surface — surfacing it is opt-in, and ggui draws nothing itself. See Error Handling → renderer-side faults for the full observability catalog.

  • React SDKuseMcpAppsChat (getAuthToken / onUnauthenticated) + <AppRenderer>
  • ggui-basic-web sample — the runnable guest-token reference (Chat.tsx)
  • Error Handling — HTTP 401, JSON-RPC auth codes, and the auth-required observability event
  • Getting Started — the agent-side handshake → render → consume loop