Skip to content

Sign in with Configure

A sign in button, like Google's. The difference is what comes back. Google gives you an email. Configure gives you the email plus who the user is and what they have connected, so your product starts out already knowing them.

Continue with Configure
emailgiven_namefamily_nametoken
Before the first turnGET /v1/profile

role, company, summary, connections. Your agent's first message already knows them.

During the conversationmcp.configure.dev

their Gmail, Calendar and memory as tools your agent can call.

Before you start

Two accounts are involved, and you keep both.

Your users sign in with their Configure account. You, the developer, have one too: it's where your app lives, and where your dashboard and API keys are. Sign up at configure.dev, create your app, and you get a client_id and client_secret. Same idea as creating a project in the Google Cloud console before you can add Google sign in, except it's one API call.

Working with a coding agent? The Copy page button above copies this page as clean Markdown, and the menu has a shorter build spec ready for Claude Code, Codex, Cursor, Devin, or Kimi. Either works.

1. Add the button

html
<script src="https://configure.dev/js/configure.js"></script>

<configure-sso-button
  client-id="oc_your_client_id"
  redirect-uri="https://app.example.com/auth/configure/callback"
  scopes="profile.read profile.search profile.remember profile.commit"
  width="100%">
</configure-sso-button>

That's the front end.

No SDK required

The script tag is the whole setup, the same pattern as Google's gsi client. The configure npm package adds the backend SDK when you want it; the sign in button itself ships from the hosted script.

What your users see

Log in to Atlas
Continue with Google
Continue with Configure

The button from step 1, on your sign in page. Users go through Configure's hosted flow, phone then Google, and land back in your app signed in.

And what you see: every user who signs in shows up in your Configure dashboard, with their profile, connections and activity. It's the same account you created your app with, at configure.dev.

2. Handle the callback

The button uses PKCE, so the finish has two small pieces. The callback page reads the code and the stored PKCE verifier, posts both to your backend, and your backend does the exchange.

The callback page at your redirect URI:

html
<script src="https://configure.dev/js/configure.js"></script>
<script type="module">
  const params = new URLSearchParams(location.search);
  const state = params.get("state");
  const pkce = Configure.getSsoPkce(state);

  const r = await fetch("/auth/configure/exchange", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code: params.get("code"), code_verifier: pkce.codeVerifier }),
  }).then(r => r.json());

  Configure.completeSso({ state });
  location.href = r.next || "/app";
</script>

Your backend exchange. Same shape as Google's /userinfo flow, plus the verifier:

ts
app.post("/auth/configure/exchange", async (req, res) => {
  const { code, code_verifier } = req.body;

  // Exchange the code. Confidential client, Basic auth, like Google.
  const tok = await fetch("https://api.configure.dev/oauth/token", {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      code_verifier,
      redirect_uri: REDIRECT_URI,
      resource: "https://api.configure.dev",
    }),
  }).then(r => r.json());

  // Who just signed in?
  // /v1/profile requires all three headers: the user's Bearer token plus your
  // X-API-Key (your sk_ developer secret key from `npx configure setup`) and
  // X-Agent (your agent handle; optional if your account has a single agent,
  // required with two or more).
  const profile = await fetch("https://api.configure.dev/v1/profile", {
    headers: {
      Authorization: `Bearer ${tok.access_token}`,
      "X-API-Key": process.env.CONFIGURE_API_KEY,
      "X-Agent": process.env.CONFIGURE_AGENT,
    },
  }).then(r => r.json());

  const { id, email, email_verified, given_name, family_name } = profile.identity;

  const user = await db.users.upsertByEmail(email, { given_name, family_name, configureId: id });
  req.session.userId = user.id;

  // Keep the token. Everything in the next two steps uses it.
  await db.tokens.save(user.id, tok.access_token, tok.refresh_token);
  res.json({ next: "/app" });
});
python
import base64, httpx
from pydantic import BaseModel

class ExchangeBody(BaseModel):
    code: str
    code_verifier: str

@router.post("/auth/configure/exchange")
async def configure_exchange(request: Request, body: ExchangeBody):
    basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
    async with httpx.AsyncClient() as http:
        # Exchange the code. Confidential client, Basic auth, like Google.
        tok = (await http.post(
            "https://api.configure.dev/oauth/token",
            headers={"Authorization": f"Basic {basic}"},
            data={
                "grant_type": "authorization_code",
                "code": body.code,
                "code_verifier": body.code_verifier,
                "redirect_uri": REDIRECT_URI,
                "resource": "https://api.configure.dev",
            },
        )).json()

        # Who just signed in?
        profile = (await http.get(
            "https://api.configure.dev/v1/profile",
            headers={
                "Authorization": f"Bearer {tok['access_token']}",
                "X-API-Key": CONFIGURE_API_KEY,
                "X-Agent": CONFIGURE_AGENT,
            },
        )).json()

    ident = profile["identity"]
    user = upsert_user_by_email(
        ident["email"],
        given_name=ident.get("given_name"),
        family_name=ident.get("family_name"),
        configure_id=ident["id"],
    )
    request.session["user_id"] = user.id

    # Keep the token. Everything in the next two steps uses it.
    save_tokens(user.id, tok["access_token"], tok.get("refresh_token"))
    return {"next": "/app"}
ts
// app/auth/configure/exchange/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const { code, code_verifier } = await req.json();

  const tok = await fetch("https://api.configure.dev/oauth/token", {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      code_verifier,
      redirect_uri: REDIRECT_URI,
      resource: "https://api.configure.dev",
    }),
  }).then(r => r.json());

  const profile = await fetch("https://api.configure.dev/v1/profile", {
    headers: {
      Authorization: `Bearer ${tok.access_token}`,
      "X-API-Key": process.env.CONFIGURE_API_KEY,
      "X-Agent": process.env.CONFIGURE_AGENT,
    },
  }).then(r => r.json());

  const { id, email, given_name, family_name } = profile.identity;
  const user = await upsertUserByEmail(email, { given_name, family_name, configureId: id });
  await saveTokens(user.id, tok.access_token, tok.refresh_token);

  const res = NextResponse.json({ next: "/app" });
  res.cookies.set("session", await createSession(user.id), { httpOnly: true });
  return res;
}

Check email_verified before you skip your own confirmation email. It is true when the email comes from the Google account the user connected during sign in. Create the account with no extra step. When the user typed their email during sign in instead, you receive it with email_verified: false; confirm it yourself if your app needs proof of ownership.

3. Read their context before the first turn

One read, before your agent says anything. Now its first message can be specific instead of "tell me about yourself":

ts
// Read the user once, before your agent's first message.
const context = await fetch("https://api.configure.dev/v1/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
    "X-API-Key": process.env.CONFIGURE_API_KEY,
    "X-Agent": process.env.CONFIGURE_AGENT,
  },
}).then(r => r.json());

agent.system(`About the user:\n${JSON.stringify(context)}`);

// Point lookups hit one source over REST. agents/<name> and imports/<provider>
// are exact; a bare name like "chatgpt" matches both the import shelf and any
// agent named chatgpt:
//   GET /v1/profile/search?source=imports/chatgpt&query=writing+style

// Category boxes open over REST too (page continues a long box):
//   GET /v1/profile?box=work           /v1/profile/search?box=work
// Or over MCP with the same box id via configure_profile_read.
python
# Read the user once, before your agent's first message.
context = httpx.get(
    "https://api.configure.dev/v1/profile",
    headers={
        "Authorization": f"Bearer {token}",
        "X-API-Key": CONFIGURE_API_KEY,
        "X-Agent": CONFIGURE_AGENT,
    },
).json()

agent.system(f"About the user:\n{json.dumps(context)}")

# Point lookups hit one source over REST (agents/... or imports handles).
# REST profile reads authenticate with your API key + the user's token:
#   GET /v1/profile/search?source=imports/chatgpt&query=writing+style
#   headers: X-API-Key: <your key>, Authorization: Bearer <token>

# Category boxes open over REST too: GET /v1/profile?box=work
# (add page for long boxes), or over MCP via configure_profile_read.
bash
# The whole profile
curl https://api.configure.dev/v1/profile \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-API-Key: $CONFIGURE_API_KEY" -H "X-Agent: $CONFIGURE_AGENT"

# One source
curl "https://api.configure.dev/v1/profile/search?source=imports/chatgpt&query=writing+style" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-API-Key: $CONFIGURE_API_KEY" -H "X-Agent: $CONFIGURE_AGENT"

Plain REST works fine here, and it's deterministic. The MCP version exists so agents that already have an MCP client don't need a separate code path.

4. The best pattern: their tools, inside onboarding

The read in step 3 is a snapshot. The pattern that makes onboarding feel great goes one step further: connect your agent to Configure's MCP with the same token, use the user's tools during the conversation, and write back what onboarding learns. Their Gmail, Calendar, Drive, Notion, and memory become tools it can call. (Code connects to the https://mcp.configure.dev/mcp endpoint directly, as below; a remote MCP client like ChatGPT just pastes https://mcp.configure.dev and discovers it.)

ts
// Pseudocode: use your MCP client of choice with the Bearer token.
const configure = mcp("https://mcp.configure.dev/mcp", { token });

// Save what onboarding learns. It follows the user to every Configure app.
await configure.call("configure_profile_remember", { fact: "Evaluating us for team onboarding" });

// Read their stuff
const inbox = await configure.call("configure_gmail_search", { query: "from:configure.dev newer_than:7d" });
const week = await configure.call("configure_calendar_get", { range: "week" });

// Or act for them, from just a signup
await configure.call("configure_email_send", {
  to: "sam@configure.dev",
  subject: "Intro from onboarding",
  body: "...",
});
python
# Pseudocode: use your MCP client of choice with the Bearer token.
configure = mcp("https://mcp.configure.dev/mcp", token=token)

# Save what onboarding learns. It follows the user to every Configure app.
await configure.call("configure_profile_remember", fact="Evaluating us for team onboarding")

# Read their stuff
inbox = await configure.call("configure_gmail_search", query="from:configure.dev newer_than:7d")
week = await configure.call("configure_calendar_get", range="week")

# Or act for them, from just a signup
await configure.call(
    "configure_email_send",
    to="sam@configure.dev",
    subject="Intro from onboarding",
    body="...",
)

Action tools (configure_email_send, configure_calendar_create_event) are approval-gated: the call succeeds only through the user's confirmation/approval path. Reads never are.

The full tool surface: configure_profile_read, configure_profile_search, configure_profile_remember, configure_profile_forget, configure_profile_import, configure_profile_commit (runtime write-back; the server usually calls it for you, use remember for explicit facts), and configure_connect (mints sign-in, connect-app, and permission links server-side; never construct these links yourself). Connector tools for linked accounts: configure_gmail_search, configure_email_send (approval-gated), configure_calendar_get, configure_calendar_create_event (approval-gated), configure_drive_search, configure_notion_search.

The connections object in the profile tells your agent which of these it can actually call. If drive is false, don't offer to read their roadmap.

Example: onboarding

Jordan signed in with Configure, which connected their Gmail. Below is their onboarding agent's first message, and under it the exact configure_profile_read payload the agent loaded before the turn. Toggle their other connections and watch both grow, before Jordan has typed a word:

Jordan Lee
signed in with Configure
1 of 5 sources connected
Connections
Gmail via sign in
Each connection adds context the agent gets before its first message.
Atlas
first message

Morning Jordan. I did my homework before saying hi: Founder and CEO of Acme, team of twelve, building developer tools. I already set your workspace up around acme.dev, so no questionnaire.

Jordan hasn't typed anything yet. Atlas read their profile once, before the turn.
What Atlas read, before the turnconfigure_profile_read
{
  "identity": {
    "email": "jordan@acme.dev",
    "given_name": "Jordan",
    "family_name": "Lee",
    "role": "Founder & CEO",
    "company": "Acme",
    "company_website": "acme.dev",
    "location": "San Francisco, CA"
  },
  "summary": "Founder and CEO of Acme (acme.dev). Runs a twelve person team building developer tools. Current focus is onboarding and activation. Prefers direct answers, dislikes forms, would rather point at an existing doc than restate it. …",
  "connections": {
    "gmail": true
  },
  "boxes": [
    {
      "id": "work",
      "kind": "category",
      "title": "Work"
    },
    {
      "id": "preferences-and-taste",
      "kind": "category",
      "title": "Preferences and taste"
    }
  ],
  "sources": [
    {
      "id": "agents/atlas",
      "kind": "source",
      "title": "Atlas"
    }
  ]
}

The context schema

json
{
  "identity": {
    "email": "jordan@acme.dev",
    "email_verified": true,
    "given_name": "Jordan",
    "family_name": "Lee",
    "role": "Founder & CEO",
    "company": "Acme",
    "company_website": "acme.dev",
    "location": "San Francisco, CA"
  },
  "summary": "Founder and CEO of Acme (acme.dev)...",
  "connections": { "gmail": true, "calendar": true, "drive": false },
  "boxes": [
    { "id": "work", "kind": "category", "title": "Work", "count": 12 },
    { "id": "preferences-and-taste", "kind": "category", "title": "Preferences and taste", "count": 41 }
  ],
  "sources": [
    { "id": "imports/chatgpt", "kind": "source", "title": "ChatGPT", "count": 79 },
    { "id": "agents/atlas", "kind": "source", "title": "Atlas", "count": 1 }
  ]
}
FieldWhere it comes from
identity.emailThe Google account connected at sign in. Verified.
identity.given_name / family_nameThe Google account.
identity.roleDerived from the user's summary and memories. Can be null.
identity.companyDerived. Can be null.
identity.company_websiteDerived from their summary, or their work email domain. Can be null.
identity.locationDerived. Can be null.
summaryA one paragraph picture of the user, built from their memory.
connectionsWhich tools the user has connected, which is also which tools your agent can call in step 4.
boxesCategory boxes, the judged truth Configure vouches for (work, preferences-and-taste, identity). Open one with configure_profile_read and box: "<id>".
sourcesSource boxes, other agents' memories as attributed testimony (imports/chatgpt, agents/...). Open the same way, or point-lookup with configure_profile_search and source=<id>.

"Derived" means Configure extracted the value from the user's summary and memories rather than from a form field. A new user with a thin profile may not have mentioned a company anywhere, so plan for nulls. Store what's always present (email, name), and fall back for the rest:

ts
const { identity } = context;

// Fall back to the email domain when there's no derived website
const domain = identity.email.split("@")[1];
const freemail = ["gmail.com", "outlook.com", "yahoo.com", "icloud.com"].includes(domain);
const website = identity.company_website ?? (freemail ? null : domain);

// Anything still null is a question for onboarding, not a blocker
const role = identity.role ?? null;

Next

Personalization infrastructure for agents