HomeDocsConnecting Your Users' Tools

Connecting Your Users' Tools

The external-user model: how your agent acts on your customer's Gmail without your backend ever holding their credential.

Fabriq DeveloperExternal usersOAuth & API keys

The model

Your product has its own users, with your own login and your own user ids. Agentic Fabriq calls them external users — external to Agentic Fabriq, internal to you. They never create an Agentic Fabriq account and never see the Agentic Fabriq console.

your product                Agentic Fabriq              the provider
────────────                ──────────────              ────────────
initiate_connection(req) ──▶ a connect URL
        │                                                     ▲
        └── you show it to your user ─────────────────────────┘
                             credential stored in AF's vault
                             (it never touches your backend)

for_user(req) ─────────────▶ short-lived, user-scoped token

session.call_tool(...) ────▶ governed, recorded, revocable calls
                             made as that user

The connection is keyed to that user id and reused on every later call, so step one happens once per user per tool, and steps two and three happen on every request.


Prerequisites

  • A registered agent with b2b2c_enabled set. Without it, every external-user endpoint answers 403.
  • Its app ID and app secret, kept server-side.
  • A redirect URL (b2b2c_oauth_callback_url) — where your user lands after connecting.

Identity: you pass the request, not a user id

Both initiate_connection and for_user take your framework's request or session object. The SDK reads the user id from the session your own auth already established.

This is deliberate. If a user id were a parameter, it would be something your code — or an agent inside it — could get wrong or invent. With no id to pass, the SDK acts as whoever your framework says is signed in, and nobody else.

What the SDK recognises

Extraction is by shape, so the SDK depends on none of these frameworks. It looks for sub, id, user_id or userId, on the request itself and inside the usual containers (user, session, auth, currentUser), plus request.state and request.session. That covers Clerk (req.auth), Auth0 / NextAuth, Passport and Express (req.user), express-session, and FastAPI / Starlette.

If yours is not recognised, pass an extractor:

# Python
session = await af.for_user(request, extractor=lambda r: r.ctx.current_user.id)
// TypeScript
const session = await af.forUser(req, { extractor: (r) => r.ctx.currentUser.id });

An extractor that returns nothing means "could not authenticate": the SDK raises rather than falling back to some default user.

The escape hatch

Sometimes you genuinely hold a trusted id — a background job acting for a known user. Say so explicitly, with a keyword that is hard to type by accident and easy to grep for in review:

# Python
session = await af.for_user(None, dangerously_use_raw_user_id="sarah_42")
// TypeScript
const session = await af.forUser(null, { dangerouslyUseRawUserId: "sarah_42" });
Passing a bare string where a request is expected raises a TypeError. That is the guard working, not a bug.

Rules for the id itself

At the API level the field is external_user_id. It must be non-empty, at most 255 characters, and must not contain :, / or \ — those break internal routing and vault paths. It is unique per organization, so the same id in two of your agents is the same person.


OAuth tools: a login URL

url = await af.initiate_connection(request, provider="gmail")

Under the hood this is POST /api/v1/apps/{app_id}/external-users/{external_user_id}/oauth/{provider}/initiate, which answers with:

{
  "oauth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "state": "...",
  "connection_id": "default"
}

The SDK returns oauth_url directly. Send your user there. The state value is a single-use ticket that expires in 10 minutes; the provider redirects back to Agentic Fabriq, the code is exchanged server-side, and the tokens go straight into the vault.

Providers with an external-user OAuth flow

These are the values provider accepts today. Anything else returns 400 Unsupported OAuth provider.

gmailgoogle_drivegoogle_docsgoogle_sheetsgoogle_slidesgoogle_calendargoogle_meetgoogle_formsgoogle_contactsgoogle_chatslackgithubnotion

The wider Integration Catalogue lists every connector Agentic Fabriq supports; the subset above is what the external-user connect flow can currently drive end to end.

Where your user lands afterwards

On success Agentic Fabriq redirects to your agent's b2b2c_oauth_callback_url with status, tool, connection_id and external_user_id in the query string. Errors go to Agentic Fabriq's own hosted result page rather than to yours.


API-key tools: a credential-entry URL

Some products have no OAuth flow worth running. Same call, one extra argument. Your user pastes their own key on an Agentic Fabriq page; it is validated against the provider before anything is stored; you still never see it.

# Python
url = await af.initiate_connection(request, provider="notion", method="api_key")
// TypeScript
const url = await af.initiateConnection(req, { provider: "notion", method: "api_key" });

This hits POST /api/v1/apps/{app_id}/external-users/{external_user_id}/credentials/{provider}/initiate and returns a different field — connect_url:

{
  "connect_url": "https://dashboard.agenticfabriq.com/connect/key#<ticket>",
  "connection_id": "default",
  "expires_in": 300
}
  • The ticket lives in the URL fragment, so it never reaches a server log or a Referer header.
  • It is single-use and expires in 5 minutes. The user gets 3 attempts to enter a valid credential.
  • The credential is probed against the provider before it is stored, so a typo fails on the page rather than at the first tool call.
Today the hosted credential page supports Notion only. Requesting method="api_key" for any other provider returns 400 and points you at the OAuth initiate endpoint. Other API-key connectors in the catalogue are connected through the console rather than through this end-user flow.

The connection is stored and reused

A connection is keyed by organization, agent, external user id, tool and connection_id. The credential itself lives in the vault, at a path derived from those same values — never in a database column and never in a response body.

On a tool call, your agent passes nothing identifying the user: identity rides in the token for_user minted. If the call's arguments carry no connection_id, Agentic Fabriq auto-selects that user's connection for the tool's provider.

connection_id defaults to "default". Pass a different one when a single user needs two accounts on the same provider — a work Gmail and a personal one, say — then pin the call by including connection_id in the tool arguments.

await af.initiate_connection(request, provider="gmail", connection_id="work")
await af.initiate_connection(request, provider="gmail", connection_id="personal")

async with await af.for_user(request) as session:
    await session.call_tool(
        "google_gmail_list_messages",
        {"max_results": 5, "connection_id": "work"},
    )

Users Agentic Fabriq has never seen

Requesting a token for an unknown user returns 404. Implicit creation is off by default, and that is on purpose: it makes a typo an error instead of a silently-created ghost user.

The SDK handles this for you — for_user creates the user and retries once (set auto_provision=False / autoProvision: false on the constructor to turn that off). If you call the API directly, create the user first:

curl -X POST https://dashboard.agenticfabriq.com/api/v1/apps/$APP_ID/external-users \
  -H "X-App-Id: $APP_ID" \
  -H "X-App-Secret: $APP_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"external_user_id": "sarah_42", "email": "sarah@example.com", "display_name": "Sarah"}'

Or from the SDK: await af.create_user("sarah_42", email="sarah@example.com") / await af.createUser("sarah_42", { email: "sarah@example.com" }). Creating a user who already exists is not an error.


How strongly was the user proven?

Every token records auth_level — how the user was established. It answers a question a shared secret alone cannot: did a human authorize this, or did an app merely say so? The value rides on the token and is shown against every live session in the console.

auth_levelWhat it provesHow a token gets it
app_assertedYour app named this user, under its shared secretThe default. You request a token; Agentic Fabriq takes your word.
app_signedYour app named this user under a key we cannot forgeYou sign a short-lived assertion with a private key Agentic Fabriq never holds.
user_presentA human was at the keyboard, moments agoThe user just completed a connection and Agentic Fabriq witnessed it.
sso_sessionAn organization member signed in through SSOConsole sessions — not part of the external-user flow.

Climbing to app_signed

Generate a key under Settings → Connect & webhooks. You keep the private half; Agentic Fabriq stores only the public half — which is exactly what makes a signed assertion worth more than a shared secret. Configure it and the SDK signs automatically:

# Python — or set AF_PRIVATE_KEY in the environment
af = AgenticFabriq(private_key=open("af_private_key.pem").read())
session = await af.for_user(request)   # now app_signed
// TypeScript
const af = new AgenticFabriq({ privateKey: process.env.AF_PRIVATE_KEY });
const session = await af.forUser(req);  // now app_signed

Each assertion is a 60-second, single-use EdDSA JWT naming exactly one user, sent as the X-App-Assertion header. Once signing works you can turn on Require signed assertions, after which unsigned token requests are refused with a 401.

user_present

When a user completes a connection, Agentic Fabriq sets a short-lived, opaque cookie on their browser. Your frontend redeems it at POST /api/v1/external-users/presence/token for a token carrying auth_level: "user_present". Presence is not renewable — "a human was here" is a statement about a moment, and it decays. To act as a present user again, send them through the connect flow again.


When a credential stops working

If the provider later rejects a stored credential, the connection flips to expired and Agentic Fabriq sends you a connection.needs_reauth webhook. Mint a fresh link with replace and send the user there:

url = await af.initiate_connection(
    request, provider="notion", method="api_key", replace=True,
)

See Triggers & Webhooks for the payload and how to verify its signature.


Managing users and connections

ActionEndpoint
Get a userGET /api/v1/apps/{app_id}/external-users/{external_user_id}
Delete a user (and purge their connections)DELETE /api/v1/apps/{app_id}/external-users/{external_user_id}
List a user’s connectionsGET /api/v1/apps/{app_id}/external-users/{external_user_id}/connections
Disconnect one toolPOST /api/v1/apps/{app_id}/external-users/{external_user_id}/connections/{connection_id}/disconnect?tool=
Delete a connection outrightDELETE /api/v1/apps/{app_id}/external-users/{external_user_id}/connections/{connection_id}?tool=

From the terminal, afctl users list shows everyone your product has connected. In the console they are under Users. Full request and response shapes live in External Users (B2B2C).

Need help?

Our team is here to help you get started.