Skip to main content
Agents

Sign in with ChatGPT

Let users connect their ChatGPT plan so your agents run on it, with no OpenAI API key.

Prefer to read code? See the full example on GitHub. View on GitHub
UserchatgptLoginOpenAIcredentialsstart()sign-in URLsign in, Continueredirect to 127.0.0.1finish(url)exchange codetokenssave login

What we’ll build:

  • A Continue with ChatGPT flow that connects a user’s ChatGPT Plus or Pro plan to your app.
  • A chatgptLogin Actor per user that runs the sign-in with pi-ai and saves the result.
  • A credentials Actor per user that keeps the login and refreshes its access token.
  • An agent that runs on the user’s ChatGPT plan, with no OpenAI API key.

Related docs:

This flow is for open-source and self-hosted apps. To offer it in a paid or hosted app, fill out OpenAI’s interest form.

Install

npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-coding-agent rivetkit

Create a host ID

OpenAI identifies each deployment of your app by an agent host ID. Generate a UUID once and set it on every worker:

export CHATGPT_HOST_ID=$(node -e 'console.log(crypto.randomUUID())')

Keep the same value across deploys. It isn’t a secret.

Store the login

The credentials Actor holds each user’s login. It gives the agent the access token, never the refresh token, and refreshes it when it’s about to expire:

import { type Credential, InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { PiProviderCredential } from "@rivet-dev/pi";
import { actor } from "rivetkit";

// One per user. Holds the user's ChatGPT login and gives the agent fresh access tokens.
export const credentials = actor({
	state: { saved: {} as Record<string, Credential> },
	actions: {
		save: (c, provider: string, credential: Credential) => {
			c.state.saved[provider] = credential;
		},
		list: (c) => Object.entries(c.state.saved).map(([providerId, { type }]) => ({ providerId, type })),
		read: (c, provider: string) => withoutRefreshToken(c.state.saved[provider]),
		refresh: async (c, provider: string) => {
			const store = new InMemoryCredentialStore();
			await store.modify(provider, async () => c.state.saved[provider]);
			const runtime = await ModelRuntime.create({ credentials: store, modelsPath: null });
			await runtime.getAuth(provider, { minOAuthValidityMs: 10 * 60_000 });
			const refreshed = await store.read(provider);
			if (refreshed) c.state.saved[provider] = refreshed;
			return withoutRefreshToken(refreshed);
		},
	},
});

function withoutRefreshToken(credential: Credential | undefined): PiProviderCredential | undefined {
	if (credential?.type !== "oauth") return credential;
	const { refresh: _refresh, ...rest } = credential;
	return rest;
}

Run the sign-in

The chatgptLogin Actor drives pi-ai’s Sign in with ChatGPT flow:

import { type Credential, createModels, InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { actor, type Registry, UserError } from "rivetkit";
import type { credentials } from "./credentials";

// A UUID you generate once for this deployment. OpenAI calls it the agent host ID.
const HOST_ID = process.env.CHATGPT_HOST_ID ?? "";
// Your app's name. Users see it on the ChatGPT consent screen.
const APP_NAME = "Acme";

interface PendingSignIn {
	cancel: () => void;
	pasteRedirect: (url: string) => void;
	credential: Promise<Credential>;
}

// One per user. Runs one sign-in at a time and saves the result to the user's credentials Actor.
export const chatgptLogin = actor({
	createVars: () => ({ pending: undefined as PendingSignIn | undefined }),
	actions: {
		// Starts a sign-in and returns the URL to open in the user's browser.
		start: async (c) => {
			c.vars.pending?.cancel();

			const models = createModels({ credentials: new InMemoryCredentialStore() });
			models.setProvider(openaiProvider());

			const cancel = new AbortController();
			const authUrl = Promise.withResolvers<string>();
			const redirect = Promise.withResolvers<string>();
			const credential = models.login(
				"openai",
				"oauth",
				{
					// Give up after ten minutes, or when the user starts over.
					signal: AbortSignal.any([cancel.signal, AbortSignal.timeout(10 * 60_000)]),
					notify: (event) => {
						if (event.type === "auth_url") authUrl.resolve(event.url);
					},
					// pi-ai asks for the redirect URL the browser landed on. Rejecting on abort
					// lets pi-ai close its callback server, so the next sign-in can start.
					prompt: ({ signal }) =>
						new Promise<string>((resolve, reject) => {
							signal?.addEventListener("abort", () => reject(new Error("Sign-in cancelled.")), { once: true });
							redirect.promise.then(resolve);
						}),
				},
				{ getDeviceId: () => HOST_ID, agentName: APP_NAME },
			);

			c.vars.pending = { cancel: () => cancel.abort(), pasteRedirect: redirect.resolve, credential };
			// Stay awake until the sign-in finishes, fails, or times out.
			c.keepAwake(credential.catch(() => undefined));

			// Rejects here if the sign-in fails before it has a URL.
			return Promise.race([authUrl.promise, credential.then(() => authUrl.promise)]);
		},

		// Finishes the sign-in with the redirect URL the user pasted. Without one, it waits
		// for the browser to reach the callback on its own, which works only when the worker
		// runs on the same machine as the browser.
		finish: async (c, redirectUrl?: string) => {
			const pending = c.vars.pending;
			if (!pending) throw new UserError("Start a sign-in first.");
			if (redirectUrl) pending.pasteRedirect(redirectUrl);

			try {
				const credential = await pending.credential;
				const client = c.client<Registry<{ credentials: typeof credentials }>>();
				await client.credentials.getOrCreate([c.key[0]]).save("openai", credential);
			} catch (error) {
				throw new UserError(error instanceof Error ? error.message : "ChatGPT sign-in failed.");
			} finally {
				c.vars.pending = undefined;
			}
		},
	},
});
  • start returns the URL to open. Calling it again cancels the sign-in in progress.
  • OpenAI sends the browser back to http://127.0.0.1:1455/auth/callback. When the worker runs on another machine, that page doesn’t load, and the user pastes its URL into your app.
  • finish exchanges the code and saves the login with the provider id openai.
  • A sign-in that isn’t finished within ten minutes is cancelled.

Define the agent

import { createRegistry } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { type Registry, setup } from "rivetkit";
import { chatgptLogin } from "./chatgpt-login";
import { credentials } from "./credentials";

const agent = pi({
	model: "openai/gpt-6-sol",
	registry: createRegistry(),
	// The agent's key starts with the user id, so it runs on that user's ChatGPT plan.
	credentials: (c) => {
		const client = c.client<Registry<{ credentials: typeof credentials }>>();
		return client.credentials.getOrCreate([c.key[0]]);
	},
});

export const registry = setup({ use: { credentials, chatgptLogin, agent } });

registry.start();

The agent reads the user’s credential before every model call, so it can use OpenAI models as soon as the user signs in.

Connect from your app

import { createInterface } from "node:readline/promises";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";

const client = createClient<typeof registry>();
const userId = "user-123";

// 1. Start the sign-in and send the user to ChatGPT.
const login = client.chatgptLogin.getOrCreate([userId]);
const url = await login.start();
console.log(`Open this URL and choose Continue:\n${url}\n`);

// 2. The browser lands on a 127.0.0.1 URL. The user copies it from the address bar.
const terminal = createInterface({ input: process.stdin, output: process.stdout });
const redirect = await terminal.question("Paste the URL you landed on, or press Enter if the page says you can close it: ");
terminal.close();
await login.finish(redirect.trim() || undefined);

// 3. The agent now runs on the user's ChatGPT plan.
const result = await client.agent.getOrCreate([userId, "chat"]).prompt("Say hi in five words.");
console.log(result.status === "done" ? result.text : `Unanswered: ${result.reason}`);

In a web app, open the URL in a new tab and show a field for the pasted URL. Label the button Continue with ChatGPT, as OpenAI’s guidelines require.

Limitations

  • One sign-in at a time per worker. pi-ai listens on port 1455 during a sign-in, so a second user’s start on the same worker fails until the first finishes or is cancelled.
  • Only the Responses API. ChatGPT plan tokens can’t call OpenAI classifier models.
  • Usage counts against the user’s plan. Users set limits for your app in their ChatGPT settings.

Protect both Actors with authentication. credentials returns access tokens, and anyone who can call finish can save a login to that user.