Connecting Your Users' Tools
The external-user model: how your agent acts on your customer's Gmail without your backend ever holding their credential.
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 userThe 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_enabledset. 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" });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_chatslackgithubnotionThe 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
Refererheader. - 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.
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_level | What it proves | How a token gets it |
|---|---|---|
app_asserted | Your app named this user, under its shared secret | The default. You request a token; Agentic Fabriq takes your word. |
app_signed | Your app named this user under a key we cannot forge | You sign a short-lived assertion with a private key Agentic Fabriq never holds. |
user_present | A human was at the keyboard, moments ago | The user just completed a connection and Agentic Fabriq witnessed it. |
sso_session | An organization member signed in through SSO | Console 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_signedEach 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
| Action | Endpoint |
|---|---|
| Get a user | GET /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 connections | GET /api/v1/apps/{app_id}/external-users/{external_user_id}/connections |
| Disconnect one tool | POST /api/v1/apps/{app_id}/external-users/{external_user_id}/connections/{connection_id}/disconnect?tool= |
| Delete a connection outright | DELETE /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.