HomeDocsDeveloper Agent Prompt

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, 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.

===== PROMPT END =====

What the coding agent will build

  • A capability check that reads providers/capabilities on load, so the connect UI offers only what this deployment can actually connect.
  • A connect route with exactly two branches, on oauth || key_connect: OAuth providers go to oauth/{provider}/initiate and a redirect to oauth_url; everything else goes to credentials/{provider}/initiate and a redirect to the AF-hosted connect_url — whether that page asks for a pasted credential or a sign-in. Both send the signed-in end user to a page AF controls, and neither is an admin step.
  • A call path that mints a short-lived user token, points MCP at /mcp/external explicitly, and lists tools before handing them to your model.
  • A callback and webhook pair that resolves identity from your own session, verifies X-AF-Signature over the raw body, and reports success only for connections whose status is connected.

Related

Need help?

Our team is here to help you get started.