HomeDocsDeveloper Quickstart

Developer Quickstart: Connect a User's Tool to Your AI App

Let your app's users connect their own Gmail, Slack, Notion or GitHub account, then call those accounts from your agent. Three steps, and the middle one is a prompt you paste into your coding agent.

Fabriq DeveloperCoding agent promptPython SDK

Fabriq Developer is for building products whose agents act on your users' own accounts — their Gmail, their Slack, their Notion — without those users ever creating an Agentic Fabriq account. You keep the customer relationship. Agentic Fabriq holds the credentials, and governs and records every call made against them.

The shape, end to end: your app authenticates its own user with your own login; you ask Agentic Fabriq for a connect URL and send that one user to it; they authorise at the provider themselves; the credential lands in Agentic Fabriq's vault. Afterwards, every call that names the same user uses the stored connection. Your agent never holds a long-lived credential — it mints a short-lived, user-scoped token per unit of work.

The three steps below are one path, not a menu. Step 2 hands the work to your coding agent, so you do not have to read the SDK to get a first call working. If you would rather wire it by hand, everything you need is still here — in SDK, CLI and reference at the bottom of the page.

1. Register your agent on Fabriq

Create an app identity and pick which tools it may reach — Gmail, Slack, GitHub and more. You get the AF_APP_ID / AF_APP_SECRET your backend signs with. In the console, go to Agents → Register.

Register agent →

Connecting through Agentic Fabriq's shared Google app needs a paid plan and a one-time attestation; your own Google app works on every plan.

The app secret is shown once. Store it in your backend's secret manager. It authenticates your server to Agentic Fabriq.

Everything in this list is server-side only. None of it belongs in frontend code, in version control, in a browser, or in an agent's prompt or tool arguments:

  • AF_APP_SECRET — proves which app is calling.
  • AF_PRIVATE_KEY — the private half of the signing key, also shown once. Agentic Fabriq stores only the public half.
  • AF_WEBHOOK_SIGNING_SECRET — verifies that a webhook delivery really came from Agentic Fabriq.
  • The user-scoped tokens for_user mints. Do not store, log or return them; cache them in memory, per user.

The one credential your backend never holds at all is your user's own. It goes from the provider straight into Agentic Fabriq's vault.

Two settings on the agent matter for this flow, both under Settings → Connect & webhooks: the redirect URL your users return to after connecting, and (optionally) a webhook URL so your backend is told when a connection completes even if the user closes the tab.

2. Integrate Fabriq into your agent

Drop this into Claude Code, Cursor or Codex and it wires the SDK into your codebase. It covers the part coding agents get wrong on their own: the id you pass identifies one of your users, so a shared or invented one leaks one customer's data to another.

Prompt for your coding agentCopy it — you do not need to read it
You are wiring Agentic Fabriq (DEVELOPER edition — not Fabriq Enterprise) into
this codebase so our agent can call third-party tools on behalf of OUR OWN end
users. Read all of this before you write anything. Everything marked
[VERIFIED] was confirmed against the live API at dashboard.agenticfabriq.com;
where your observations differ, trust the API and update this document.

THE MODEL, IN ONE PARAGRAPH
We are the seller. We registered an app with Agentic Fabriq and hold
AF_APP_ID / AF_APP_SECRET. Our end users are "external users": they never get
an Agentic Fabriq account. Every call names one of them by an external user id
WE choose — our own primary key, e.g. "user_8412". A different id is a
different person with a different set of connected accounts. To connect an
account we ask AF for a URL, send that one end user to it, and they authorise
at the provider themselves; credentials land in AF's vault and never touch our
servers. Afterwards, any call naming that id uses the stored connection. The
agent never holds a long-lived credential: it mints a short-lived (900s)
user-scoped token per unit of work.

STEP 0 — CHECK WHAT THE SDK ACTUALLY SHIPS
  pip install agentic-fabriq-sdk        # imports as `af_sdk`

The seller-facing facade (af_sdk.b2b2c.AgenticFabriq with for_user /
initiate_connection / to_anthropic_tools) [VERIFIED on PyPI 3 November 2026] IS in the
published wheel from 0.1.88 — af_sdk/b2b2c.py, with for_user,
initiate_connection, to_anthropic_tools and dangerously_use_raw_user_id. It
was ABSENT through 0.1.87, so upgrade if you are pinned below that.
@agenticfabriq/sdk is still not on npm. Check your installed version first:

  python -c "from af_sdk.b2b2c import AgenticFabriq"   # ImportError = not shipped

If it imports, use it and skip the adapter below. If not you are below 0.1.88:
upgrade. If you cannot, what that wheel does ship is MCPClient with
first-class B2B2C support — build a thin adapter over
it plus the REST endpoints in the next section, keeping the facade's
ergonomics (for_user(request), dangerously_use_raw_user_id=, per-user token
cache). Isolate the adapter in one package so it can be deleted when the
facade ships. One SDK divergence to know: B2B2C tokens CANNOT be refreshed in
place — af_sdk raises AuthenticationError on expiry and tells you to mint a
new one. Cache per user and re-mint ~60s before the 900s expiry.

THE REST SURFACE — all [VERIFIED]
App-scoped — headers X-App-Id + X-App-Secret (+ X-App-Assertion when signing):

  POST /api/v1/apps/{app_id}/external-users
       {"external_user_id": "..."}                    create an end user (409 = exists, fine)
  POST /api/v1/apps/{app_id}/external-users/{uid}/token
       {} -> {access_token, expires_in: 900, mcp_url, ...}
       claims: sub=ext:<uid>, is_external_user=true, app_id, auth_level, aud=mcp
  GET  /api/v1/apps/{app_id}/external-users/{uid}/connections
  DELETE /api/v1/apps/{app_id}/external-users/{uid}/connections/{connection_id}?tool={provider}
       The ?tool= param is REQUIRED (422 without it): connection_id defaults
       to "default" for EVERY provider, so a user with two tools connected
       has two rows sharing that id — without ?tool= the delete is ambiguous
       and could revoke the wrong account.
  POST /api/v1/apps/{app_id}/external-users/{uid}/oauth/{provider}/initiate
       {"connection_id": "default", "scopes": [...optional...]}
       -> {oauth_url, state, connection_id, credential_source}
  POST /api/v1/apps/{app_id}/external-users/{uid}/credentials/{provider}/initiate
       {"connection_id": "default"}          paste-a-key providers
       -> {connect_url, connection_id, expires_in: 300}
  GET  /api/v1/apps/{app_id}/external-users/providers/capabilities
       -> per provider: {oauth, key_connect, link_connect,
                         af_app_configured, org_app_supported,
                         app_unavailable_message, instance_required,
                         af_app_configured_by_instance, af_app_instance_scope}
       Route on oauth || key_connect ONLY. The rest describe or explain.

Console-scoped — Authorization: Bearer <console token> (app creds get 401):
  /api/v1/triggers...                                  trigger management ONLY

MCP — Authorization: Bearer <user token>:
  POST /mcp/external                                   JSON-RPC tools/list, tools/call

CONNECTING AN ACCOUNT IS APP-SCOPED. Do not use POST /api/v1/connections/
initiate (console-scoped; app creds 401 there) — that is the wrong route.
Only triggers need a console token.

TRAPS THAT COST REAL DEBUGGING TIME — every one [VERIFIED]
1. /mcp vs /mcp/external. MCPClient defaults mcp_url to the INTERNAL /mcp
   route. External-user tokens must use /mcp/external — and BOTH routes answer
   200 for an external token, so the wrong one fails SILENTLY. Always pass
   mcp_url explicitly; prefer the mcp_url field from the token response when
   non-null, falling back to <base>/mcp/external. Pin this with a test.
2. Provider names are SURFACES, not vendors — and the authoritative list is
   the capabilities endpoint, never a list in a document, including this one.
   The convention: one key per surface (google_calendar, google_drive — not
   `google`, which is the organization-app key, a different namespace, and is
   rejected); the console may display "X (Twitter)" but the API accepts only
   `twitter`. An unknown name 400s with the valid list — read that error, it
   is accurate. Do not treat a provider's absence from any prose list as
   evidence it cannot be connected.
3. The success response key is oauth_url (or connect_url for key-connect).
4. A LISTED CONNECTION IS NOT A WORKING ONE. initiate writes a row BEFORE the
   user reaches the provider. status distinguishes them:
     configured  row exists, nothing else does — yields ZERO tools
     connected   consent completed, credential in vault — usable
   Key ALL user-facing state off status == "connected". Do NOT infer from
   granted_scopes being non-empty: some providers legitimately grant none
   (Supabase fixes permissions at app registration). Abandoned `configured`
   rows accumulate (no reaper) — treat them as debris when counting.
5. tools/list has TWO gates: the connection must exist AND the agent's
   grants must intersect the connection's scopes. connected-with-zero-tools
   is a real state — surface it honestly in your UI ("connected but no
   actions live") instead of showing "On" while the agent can do nothing.
6. Tool names may be vendor-prefixed: google_gmail_archive_message belongs to
   gmail. When attributing tools to providers, check the first TWO name
   segments against known slugs, not just the first.
7. LOCAL DEV PORT: the AF gateway itself may occupy localhost:8000 (and
   `localhost` can resolve to ::1 for one server while yours binds 127.0.0.1
   — curl works while the browser hits the wrong process). Run your app on a
   different port and make the console Redirect URL match it exactly.
8. If the agent is ACTION-mode (console: "Action-based"), its grants live in
   the actions table; historically the initiate did not derive scopes from
   them. If a completed consent yields fewer tools than the agent has actions,
   diff the authorize URL's scope= parameter against the connection row's
   granted_scopes before blaming the console config — the URL tells you what
   was actually REQUESTED, and a scope never requested can never be granted.

ONE-TIME CONSOLE SETUP (agent page → Connect & webhooks)
  Redirect URL        REQUIRED for any connect. Checked FIRST, so leaving it
                      empty masks every other misconfiguration behind
                      "B2B2C OAuth callback URL not configured". Plain
                      http://localhost:<port>/... is accepted for dev. This is
                      AF's redirect target back to YOU — providers only ever
                      see AF's own callback.
  Webhook URL         + "Generate signing secret" -> AF_WEBHOOK_SIGNING_SECRET.
                      The signed channel for connection.completed /
                      connection.needs_reauth. Note AF cannot reach localhost —
                      use a tunnel for real deliveries.
  Generate key        Ed25519 for signed assertions (below). Private half
                      shown ONCE -> AF_PRIVATE_KEY. Do not enable "Require
                      signed assertions" until a signed request succeeds.
  Create users automatically   optional; if off, token issuance 404s for
                      unknown ids — call create_user first either way (it is
                      idempotent) so the integration survives the toggle.

DRIVE CONNECTABILITY FROM THE CAPABILITIES ENDPOINT — DO NOT HARDCODE
Which providers have an AF-owned OAuth app is DEPLOYMENT STATE, not a property
of your code, and it changes without notice. Call
GET .../providers/capabilities on page load and branch IN THIS ORDER. A
provider may offer more than one path; prefer OAuth whenever it is available,
because the user authorises at the provider instead of having to supply a
credential. The credentials route is the FALLBACK, not the default.

THE CONTRACT IS `oauth || key_connect` — TWO BRANCHES, NO THIRD. Every
provider that can be connected at all answers true to one of them. If you
find yourself wanting a third route, you have misread a descriptor flag as a
routing flag; see branch 3.
  1. oauth && af_app_configured
       -> POST .../oauth/{provider}/initiate, redirect the user to oauth_url
  2. oauth && !af_app_configured && org_app_supported
       -> the block is on YOUR ORG registering its own provider app (Tools
          Guide → provider → "Use your company's own app") — say "not
          available on this deployment yet", never "no app exists", and never
          imply the end user did something wrong. If key_connect is also
          true, offer branch 3 instead of dead-ending.
  3. key_connect=true
       -> POST .../credentials/{provider}/initiate, redirect the user to
          connect_url. ONE DOOR FOR EVERY NON-OAUTH PROVIDER — there is no
          second route to find and no provider that needs one.
          READ THE FLAG'S NAME CAREFULLY: key_connect means
          "connect through the credentials initiate". It does NOT mean "the
          user pastes a key". AF hosts the page and decides what it asks for:
          a credential to paste for most providers, or a sign-in, a QR scan or
          a pairing code for the linked-account ones (whatsapp, instagram,
          telegram, linkedin), which have NOTHING to paste and show no input
          boxes at all. Your code is identical either way — redirect, then
          wait for the callback. The URL is SINGLE-USE and expires in 300s —
          mint it per click. Never cache it, pre-generate it on page load, or
          send it by email.
          link_connect tells you WHICH page the user will see, so your button
          can say "Scan a QR code" instead of "Paste your API key". It is for
          UI COPY ONLY. Never branch on it, never gate a button on it, never
          treat it as a third route — the routing flag is key_connect.
  4. none of the above               -> no end-user connect flow exists

BRANCHES 1 AND 3 ARE BOTH END-USER SELF-SERVICE. There is no admin-entered
credential path for external users: every credential, OAuth grant or pasted
key, is supplied by the end user on a page AF hosts. Do not route
key_connect providers to an admin, a console step, or a shared credential —
doing so silently gives every one of your users the same account. The only
thing an org admin ever supplies is the OAuth CLIENT app in branch 2, and
even then the end user still completes consent themselves.

af_app_configured reports the platform client — the same value every real
OAuth path reads — but NOT whether a given user already holds a BYO
credential; it answers "can a fresh external user connect here", which is the
question a connect UI asks.

instance_required is a DISPLAY HINT ONLY: the provider is addressed through a
workspace subdomain or host (Unipile, for example). AF's hosted page collects
it — do not build a field for it and do not pass it to initiate. Use it to
set expectations ("you'll need your workspace subdomain") before you send the
user over.

app_unavailable_message is the sentence an initiate WOULD return if no
application resolved at any tier. It comes from the function that raises it,
so it cannot drift from the real error — show it rather than writing your own
"unavailable" copy. Empty string means connectable.

credential_source on the OAuth initiate response names which app the grant
ran through (AF's platform client, or your org's own). Informational — not a
branch. Same for af_app_configured_by_instance and af_app_instance_scope,
which refine af_app_configured for per-instance providers.

The response SHAPE, trimmed to one provider of each kind. DO NOT COPY THESE
VALUES INTO A FIXTURE — they are deployment state and the field set grows.
Fetch the real response and commit that (VERIFY item 8):
  {"gmail":    {"oauth": true,  "key_connect": false, "link_connect": false,
                "af_app_configured": true,  "org_app_supported": true,
                "app_unavailable_message": "", "instance_required": false},
   "whatsapp": {"oauth": false, "key_connect": true,  "link_connect": true,
                "af_app_configured": true,  "org_app_supported": false,
                "app_unavailable_message": "", "instance_required": false}}
The generated map of which providers fall in which branch is the Provider
Auth Catalogue at /docs#provider-auth (OAuth providers, API-credential
providers, and hosted sign-in providers). Read it for shape; branch on the
endpoint.

gmail takes branch 1 and ends at oauth_url. whatsapp takes branch 3 and ends
at connect_url: key_connect is true because that is the ROUTE, and
link_connect is true to tell your UI the page asks the user to sign in rather
than paste anything. whatsapp is only the example — instagram, telegram and
linkedin are identical in every respect, so handle the SET, never one case.
Both end with YOUR END USER connecting their own account. Nothing here is an
admin step.

HOW THE END-USER ID IS SUPPLIED — unchanged, and the part to get exactly right
Two ways, both resolving to the same external user id:
1. for_user(request) — read the signed-in user's id from OUR auth session
   (sub / id / user_id / userId on request.state, request.session,
   request.user, request.auth; extractor=lambda r: ... for unusual shapes).
2. for_user(dangerously_use_raw_user_id="user_8412") — REQUIRED for workers,
   cron, queue consumers, webhook handlers, scripts. Spelled that way to stand
   out in review, not because it is forbidden. A bare positional string must
   raise TypeError.

NEVER derive the id from model output, a tool argument, or any
client-supplied value — including EVERY query parameter on the OAuth
callback, which is a browser redirect and therefore attacker-controlled. A
callback naming a different user than the session is logged and ignored.
Never fall back to a shared or default id; if the id cannot be resolved, fail
the request with 401.

SIGNED ASSERTIONS (auth_level: app_asserted -> app_signed) — [VERIFIED] format
The shared secret proves which APP is calling; AF takes your word for which
USER. An Ed25519 assertion proves the user too. Header X-App-Assertion,
compact JWS, alg EdDSA (pinned server-side — the header's alg is ignored).
Claims, ALL required: iss=<app_id>, sub=<RAW user id — "user_8412", NOT
"ext:user_8412"; the token uses the ext: prefix, the assertion does not — do
not copy it across>, aud="agentic-fabriq", jti=<unique per assertion;
single-use, claimed in Redis>, iat, exp with exp-iat <= 60s (use 30s for
clock skew). Requires assertion_public_key registered on the app, else 400
"No signing key is registered".

THE CONNECT CALLBACK (your Redirect URL — BOTH flows land here)
AF appends: status=success, tool, connection_id, external_user_id (error
path: status=error, tool, reason). OAuth consent and the AF-hosted credential
page return to the SAME Redirect URL with the same parameters, so ONE handler
covers both — do not build a second route for key_connect, and do not assume
a pasted credential confirms somewhere else. NOTE the provider field is
`tool` — on the
callback query, and on connection rows. NONE of it is signed: treat it as a
UI hint, resolve the user from your session, and confirm by re-reading
GET .../connections filtered to status == "connected". Never render success
from the query string alone, and never show success for a `configured` row.
The signed confirmation channel is the connection.completed webhook.

RECEIVING WEBHOOKS (triggers + connection events — one verifier covers both)
  body: {event_id, org, app, provider, event_type, subject_user, occurred_at,
         payload};  headers: X-AF-Signature, X-AF-Timestamp
  expected = hex HMAC-SHA256(signing_secret, f"{timestamp}.{raw_body}")
Compare with hmac.compare_digest against the RAW bytes (any json.loads/dumps
round-trip breaks the MAC), reject timestamps older than 300s, dedupe on
event_id (deliveries retry: 3 attempts, 5s timeout, backoff on 5xx; 4xx not
retried), return 2xx fast and do work in the background. subject_user is
trusted ONLY BECAUSE the signature verified — it names whose session to open,
via dangerously_use_raw_user_id. Triggers themselves are console-scoped and
need a paid plan; the connection.* webhooks do not.

RULES
- Do NOT invent tool names — call get_tools() and use what returns.
- Do NOT store, log, or return a token. Cache in memory per user, re-mint
  ~60s before expiry, drop the cached token on invalidate_user() and on
  connection.needs_reauth.
- Do NOT add an OAuth client library or collect provider credentials in your
  own UI. The paste-a-key flow is AF-hosted: the ticket rides in the URL
  FRAGMENT so it never reaches a server log or Referer.
- Do NOT reuse one external user id for several people.
- On an auth error, surface it — never retry under a different identity. A
  403 on external-user calls usually means B2B2C is disabled for the app
  (console setting, not code).
- Central exception handlers, not per-route try/except — one forgotten route
  otherwise leaks a 500.

VERIFY BEFORE YOU FINISH — run these, do not assume them
1. Identity: unauthenticated request -> 401; ?user_id=victim on any route
   (including the callback) is ignored in favour of the session; a bare
   positional id raises TypeError.
2. MCP route: test pins that your client passes mcp_url explicitly and it
   ends in /mcp/external.
3. Connect: the URL from initiate is actually redirected to or rendered; the
   callback resolves identity from the session and reports only
   status=="connected" rows as success.
4. Tokens: nothing greps for a token in logs; cache is per-user; expiry
   re-mints rather than refreshes.
5. Webhooks: valid delivery 2xx, tampered body 401, stale timestamp 401,
   replayed event_id deduped — exercised with the real signing secret.
6. End to end: connect one real account, then make one real tool call through
   the agent and check the reply contains provider data. tools/list returning
   [] is ambiguous (no connection? two-gate filter? wrong route?) — a
   completed tool call is the only proof the chain works.
7. If something upstream looks broken, isolate it the way that gets it fixed:
   fresh request ids, a control provider that works on the same credentials
   in the same minute, and the narrowest reproduction you can write down.
8. Capability fixtures: call providers/capabilities ONCE for real and commit
   that response as your fixture. Do NOT hand-write a capabilities dict from
   this document or any other. A mock you wrote yourself proves your code
   matches your assumption, not that it matches the API. If a provider is
   missing a flag you expected, that IS the finding — treat unknown as
   UNKNOWN, never as unsupported.

Paste it as a single message with your codebase open. Every claim marked [VERIFIED] in it was checked against the live API, and the prompt tells your coding agent to trust the API over the document where the two disagree — so it keeps working as the platform moves.

The same text, with a walkthrough of what the coding agent will build, is at Developer Agent Prompt. Building an agent for your own employees rather than your customers? Use the General Agent Prompt under Fabriq Enterprise instead.

3. An end user connects an account and your agent makes a tool call

Send one of your users the URL from initiate_connection(). Once they authorise at the provider, open a session with for_user() and call a tool from get_tools(). The call, and the identity behind it, show up in your session log.

If the tool list comes back empty, the token is not the problem. Either the connection is not connected yet — an initiate writes a configured row before the user ever reaches the provider, and that row yields zero tools — or your agent's grants and the connection's scopes do not intersect. Connected-with-zero-tools is a real state. Details in Connecting Your Users' Tools.

A completed tool call that comes back with provider data is the only proof the chain works: tools/list returning an empty array is ambiguous.

Next: react to provider events. Subscribe to Slack, Gmail or GitHub events and have them delivered to a signed webhook. The prompt above explains how to verify them; the full reference is in Triggers & Webhooks.

SDK, CLI and reference

The prompt in step 2 writes this code for you. It is here for when you want to read what it did, wire the integration by hand, or call the HTTP API directly.

Install the SDK

pip install agentic-fabriq-sdk        # Python — import as af_sdk
npm install @agenticfabriq/sdk        # TypeScript

Availability. The Python package is published and current: 0.1.88 ships af_sdk/b2b2c.py with the seller-facing facade the samples below use, confirmed against the wheel on PyPI on 3 November 2026. It was absent through 0.1.87, so if you are pinned below 0.1.88, upgrade. The TypeScript package @agenticfabriq/sdk is still not on npm. One line settles the first for your install:

python -c "from af_sdk.b2b2c import AgenticFabriq"   # ImportError = not shipped

If it imports, you are on 0.1.88 or later and can use it directly. If it raises, you are on an older release: upgrade, or, if you are pinned, what that wheel does ship is MCPClient with first-class B2B2C support — build a thin adapter over it plus the REST endpoints below, keep it in one package so it can be deleted when the facade ships, and remember that B2B2C tokens cannot be refreshed in place: cache per user and re-mint before expiry. Because the npm package is unpublished, the TypeScript track below is a reference for the shape of the client rather than something you can install today. Take the Python path for a working first call.

Environment variables

Both clients read their credentials from the environment, so a quickstart fits on one screen:

AF_APP_ID=org-xxx_my-app
AF_APP_SECRET=...
# optional — defaults to https://dashboard.agenticfabriq.com
AF_BASE_URL=https://dashboard.agenticfabriq.com
Environment variableUsed forMay leave your backend?
AF_APP_IDYour agent's app IDNot a secret, but there is no reason to ship it
AF_APP_SECRETYour agent's app secretNever
AF_BASE_URLOverride the Agentic Fabriq hostNot a secret
AF_PRIVATE_KEYOptional signing key — see proving who the user isNever
AF_WEBHOOK_SIGNING_SECRETVerifying webhook deliveries — see Triggers & WebhooksNever

Connect a user's tool

initiate_connection returns a URL. Send your user there; they approve at the provider; the credential lands in Agentic Fabriq's vault. It never passes through your backend.

Python

from af_sdk import AgenticFabriq

af = AgenticFabriq()  # reads AF_APP_ID / AF_APP_SECRET / AF_BASE_URL

# inside your request handler
url = await af.initiate_connection(request, provider="gmail")
return redirect(url)

TypeScript

import { AgenticFabriq } from "@agenticfabriq/sdk";

const af = new AgenticFabriq();

const url = await af.initiateConnection(req, { provider: "gmail" });
res.redirect(url);

This is the step to get exactly right: how your user becomes the user in the call.

You pass the request, not a user id. The SDK reads the signed-in user's id out of the session your own auth already established — it looks for sub, id, user_id or userId on request.state, request.session, request.user or request.auth — and sends it as external_user_id on the API call. That id is your own primary key. A different id is a different person with a different set of connected accounts, so never reuse one id for several people.

A client-supplied identity is never proof of who the user is. Do not derive the id from model output, from a tool argument, or from any client-supplied value — including every query parameter on the OAuth callback, which is a browser redirect and therefore attacker-controlled. A callback naming a different user than the session is logged and ignored. Never fall back to a shared or default id: if the id cannot be resolved, fail the request with a 401.

See Connecting Your Users' Tools for the extractor hook, the one explicit escape hatch for background jobs, and how to prove the user with a signed assertion instead of a shared secret.

Optional arguments: connection_id (defaults to "default", letting one user hold several accounts for the same provider) and scopes (merged with the scopes configured on the agent).

Make a governed tool call

for_user mints a short-lived, user-scoped token and hands back a session whose identity is already baked in — no method on it takes a user id.

Python

async with await af.for_user(request) as session:
    tools = await session.get_tools()          # only what this user actually granted
    result = await session.call_tool(
        "google_gmail_list_messages",
        {"max_results": 5},
    )

Optional, once that call works: if you are driving a model rather than calling tools directly, the session converts the same tool list into the shape your provider expects. Load the tools first — the adapters read the cached list:

await session.get_tools()
openai_tools    = session.to_openai_tools()
anthropic_tools = session.to_anthropic_tools()
langchain_tools = session.to_langchain_tools()   # needs langchain-core installed

TypeScript

The TypeScript SDK deliberately does not wrap an MCP client. It gives you the token and the URL; hand them to the MCP client you already use.

const session = await af.forUser(req);

session.userId;     // the id your app authenticated
session.token;      // short-lived, user-scoped bearer token
session.expiresIn;  // seconds
session.mcpUrl;     // MCP endpoint to point your client at

Point your client at session.mcpUrl with Authorization: Bearer <session.token> and speak JSON-RPC 2.0 — tools/list, then tools/call. Pass the URL explicitly rather than letting a client default: external-user tokens belong on /mcp/external, and prefer the mcpUrl from the token response over a hand-built path.

Tokens are short-lived — 900 seconds by default, with a hard ceiling of one hour. The Python client caches and re-mints them 60 seconds before expiry, so calling for_user per request is the intended pattern rather than a waste.

What just happened

The SDK is a thin wrapper over an HTTP API you can call directly. Every request authenticates with X-App-Id and X-App-Secret headers (HTTP Basic app_id:secret also works).

SDK callHTTP endpoint
create_userPOST /api/v1/apps/{app_id}/external-users
initiate_connectionPOST /api/v1/apps/{app_id}/external-users/{external_user_id}/oauth/{provider}/initiate
for_userPOST /api/v1/apps/{app_id}/external-users/{external_user_id}/token
session.call_toolPOST /mcp/external (JSON-RPC 2.0, bearer token)

The full request and response shapes are in External Users (B2B2C).

The CLI comes with the Python SDK

Installing agentic-fabriq-sdk also installs afctl. The commands most useful while building:

afctl users list                       # everyone your product has connected
afctl sessions list --population external   # live tokens, and the proof behind each
afctl sessions revoke <jti>            # kill one immediately
afctl triggers list                    # provider-event subscriptions
afctl triggers deliveries              # did my webhook arrive?

Full command reference: CLI Reference (afctl).

What to read next

Need help?

Our team is here to help you get started.