Developer Agent Prompt
Paste everything between the markers into Claude Code (or another coding agent) when you are wiring Agentic Fabriq developer edition into your product — your own end users connect their own Gmail, Slack, GitHub, or Notion accounts, and your agent calls those tools on their behalf.
This is the prompt for the developer edition (pip install agentic-fabriq-sdk, imports as af_sdk), where you hold an app id and secret and every call names one of your end users. If you are building an agent that runs inside your own organization instead — your employees, your workspace, governed centrally — use the General Agent Prompt under Fabriq Enterprise.
Every claim marked [VERIFIED] in the prompt was checked against the live API at dashboard.agenticfabriq.com. The prompt tells the coding agent to trust the API over the document where the two disagree, so it stays useful as the platform moves.
Open your codebase in the coding agent and paste everything between the ===== PROMPT START ===== and ===== PROMPT END ===== markers below as a single message.
===== PROMPT START =====
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) exists in the vendor's source tree
but [VERIFIED through 0.1.87] is ABSENT from the published wheel, and
@agenticfabriq/sdk is 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, what the 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, af_app_configured, org_app_supported,
key_connect, instance_required}
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. Valid: gmail, google_calendar,
google_drive (etc — one per Google surface), github, notion, slack,
airtable, linear, quickbooks, sharepoint, supabase, twitter. The console
may display "X (Twitter)" but the API accepts only `twitter`; `google` is
the organization-app key, a different namespace, and is rejected. An
unknown name 400s with the valid list — read that error, it is accurate.
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 (it changed twice while this
integration was built). Call GET .../providers/capabilities on page load:
key_connect=true -> paste-a-key flow via /credentials/
oauth && af_app_configured -> connectable now on AF's app
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
none of the above -> no end-user connect flow exists
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.
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 OAUTH CALLBACK (your Redirect URL)
AF appends: status=success, tool, connection_id, external_user_id (error
path: status=error, tool, reason). 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.
===== PROMPT END =====
What the coding agent will build
- A capability check that reads
providers/capabilitieson load, so the connect UI offers only what this deployment can actually connect. - A connect route that posts to the app-scoped
oauth/{provider}/initiateand sends the signed-in end user to the URL it returns. - A call path that mints a short-lived user token, points MCP at
/mcp/externalexplicitly, and lists tools before handing them to your model. - A callback and webhook pair that resolves identity from your own session, verifies
X-AF-Signatureover the raw body, and reports success only for connections whosestatusisconnected.
Related
- Developer Quickstart — the same integration written by hand.
- Connecting Your Users' Tools — the connection lifecycle in detail.
- Triggers & Webhooks — creating triggers and verifying deliveries.
- Integration Catalogue — every provider your users can connect.
Need help?
Our team is here to help you get started.