Per-customer MCP access
This guide is the customer-connection branch of Create, Freeze, and Embed One Agent. Start there if you have not yet created, tested, and frozen the agent.
The invariant is:
one AgentTask
-> one reviewed frozen version
-> one stable embedded deployment
-> many external_user_id principals
-> each principal's own MCP connections
Do not create one agent per customer. Do not split the job into a second agent because a customer has different credentials. The frozen logic is shared; connection bindings are isolated per customer.
What external_user_id changes
Your product calls FlyMyAI with two identities that have different jobs:
| Identity | Purpose |
|---|---|
Builder X-API-KEY | Owns the agent and deployment, authorizes backend calls, and is billed by FlyMyAI |
external_user_id | Selects one product customer's principal and supported MCP connection bindings |
Passing external_user_id on the stable deployment run switches supported
hosted connector resolution to that customer. It does not create a FlyMyAI
account for the customer and does not switch the billed account.
Keep the builder key in your backend secret manager. Derive
external_user_id from the authenticated product session. It should be a
stable, opaque, non-secret ID from your system, not an email address and not a
value accepted directly from an untrusted browser request. It must be at most
255 characters and must not start with the reserved flymyai-owner- prefix.
Optional customer-bound MCP runtime
REST remains the control plane for publish and hosted connection setup. If the customer's assistant should call the already-published deployment through MCP, run a separate gateway process with one immutable deployment/customer binding per gateway process:
FLYMYAI_API_KEY='<server-owner-key>' \
MCP_HTTP_TOKEN='<customer-edge-token>' \
FLYMYAI_MCP_MODE=customer \
FLYMYAI_MCP_DEPLOYMENT_ID="$DEPLOYMENT_ID" \
FLYMYAI_MCP_EXTERNAL_USER_ID="$AUTHENTICATED_PRODUCT_USER_ID" \
FLYMYAI_MCP_RESOURCE_SET_ID="$CUSTOMER_RESOURCE_SET_ID" \
FLYMYAI_MCP_RESOURCE_SET_REVISION="$CUSTOMER_RESOURCE_SET_REVISION" \
npm start
Your authenticated backend supplies external_user_id during trusted process
provisioning. It is never a model-call argument, browser-selected value,
arbitrary MCP header, or OAuth user_id. Omit both resource-set variables for
saved FlyMyAI bindings, or use FLYMYAI_MCP_CONNECTIONS_JSON instead for one
exact process-managed mapping.
The client authenticates with Authorization: Bearer <customer-edge-token>
and never receives the owner API key. This surface exposes only:
run = run_bound_deployment({
"variables":{"since":"2026-08-15"},
"operation_key":"customer-run-<uuid>"
})
page = get_bound_deployment_run({"run_handle":run.run_handle})
Continue with next_since while has_more=true and stop only at
poll_complete=true. The handle and namespaced retry key are bound to the
configured owner, deployment, and customer. Route another authenticated user
to a separately trusted binding. The same deployment across two bindings must
still create different ExternalPrincipal and execution rows while retaining
one agent, one version, one deployment, and one billed owner.
Before publishing
An embedded deployment publishes an immutable version created from a successful instruction freeze. Its access manifest declares every connector slot the version can use.
If the agent was authored through MCP OAuth, first call MCP whoami and call
GET $FLYMYAI_AGENTS_API_ROOT/me/ with the REST builder key after selecting
the exact environment below.
Require the same user_id. Stop on a mismatch because one builder cannot
publish another owner's agent.
Inspect the draft deployment:
export FLYMYAI_AGENTS_API_ROOT=https://backend.flymy.ai/api/v1/agents
: "${FLYMYAI_AGENTS_API_ROOT:?Set the exact Agents API root}"
case "$FLYMYAI_AGENTS_API_ROOT" in */api/v1/agents) ;; *) exit 1 ;; esac
BASE="${FLYMYAI_AGENTS_API_ROOT%/}"
curl -fsS \
"$BASE/deployments/$DEPLOYMENT_ID/access/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
| jq '[.requirements[] | {
slot,
toolkit_slug,
cardinality,
connection_required,
hosted_setup_supported
}]'
The value shown is released production. A release-candidate test must receive its exact candidate root and must never fall back to production.
Then run the server-side preflight:
curl -fsS -X POST \
"$BASE/deployments/$DEPLOYMENT_ID/preflight/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"publish_mode":"embedded"}'
Publish only when the response is {"ready": true}:
curl -fsS -X POST \
"$BASE/deployments/$DEPLOYMENT_ID/publish/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"publish_mode":"embedded"}'
A connector that works for the builder in a normal agent run is not automatically supported in an embedded customer deployment. Preflight currently rejects unsupported adapters, arbitrary custom MCP servers, mutable skills, and other requirements without a hosted customer setup path. Do not bypass a failed preflight or hide it by splitting the logical worker into multiple agents.
Choose who manages the customer mapping
external_user_id selects one isolated external principal. It does not select
a connector account or mapping by itself. Choose one of these run contracts:
| Mode | Deployment run fields | Use when |
|---|---|---|
| FlyMyAI-managed | Omit both resource_set_id and connections | Hosted setup and saved ConnectionBinding rows are the source of truth |
| Customer-managed named mapping | Send resource_set_id and its saved resource_set_revision | Your backend stores a stable opaque mapping ID for this customer |
| Explicit one-off mapping | Send connections keyed by slot | Your backend owns the complete mapping for this logical request |
resource_set_id and connections are mutually exclusive. A revision without
a resource-set ID is also invalid. The backend still accepts an omitted named
mapping revision for compatibility, but the public safe workflow requires both
saved values so stale state fails closed.
A customer-managed named mapping is an MCP resource set rooted at that exact
ExternalPrincipal. Its members are exact integration_connection public
UUIDs for the same principal. Cross-principal IDs fail closed. Store the set's
public ID and revision alongside your own customer and deployment record. The
ID is opaque and does not expose credentials.
Use the full creation and revision-checked replacement example in Multiple MCP Accounts and Resource Sets. Hosted authorization below is still how the customer creates each supported connection; the mapping mode controls how a run selects among those connections.
Connect one customer's account
Call access with the same external ID you will use for runs:
curl -fsS -G \
"$BASE/deployments/$DEPLOYMENT_ID/access/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
--data-urlencode "external_user_id=$CUSTOMER_ID"
This GET is read-only. It can return requirements and existing customer state,
but it does not create an ExternalPrincipal or a connection. For a new
customer, the first mutating bootstrap is the connect-session POST below.
For each requirement where connection_required is true, create a short-lived
hosted authorization session using the exact returned slot:
connect-session has no caller idempotency key. Persist the canonical
deployment, customer, slot, and audit label before one dispatch. If its response
is lost, do not immediately create another session. Reconcile through access
and the known 15-minute window, and retain an unknown outcome if uniqueness
cannot be proven.
curl -fsS -X POST \
"$BASE/deployments/$DEPLOYMENT_ID/connect-session/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -nc \
--arg external_user_id "$CUSTOMER_ID" \
--arg slot "$SLOT" \
--arg alias "Work account" \
'{external_user_id:$external_user_id,slot:$slot,alias:$alias}')"
The response contains redirect_url, expires_at, and provider. Keep the
complete access and setup responses on the builder backend; send only the
redirect URL to that authenticated customer. It expires after 15 minutes
and is single-use. The customer authorizes the third-party service there; they
never receive your FlyMyAI key. This POST resolves or creates the exact
principal for (deployment_id, external_user_id) before it creates the hosted
session.
After the redirect completes, poll access with bounded backoff. Check that the
required connection is active and unexpired, not merely that a binding row
exists. The deployment run remains the authoritative check because an account
can be revoked between setup and execution.
Run the same agent for that customer
Use the stable deployment ID and pass the customer's ID on every run:
import os
import time
import requests
base = os.environ["FLYMYAI_AGENTS_API_ROOT"].rstrip("/")
if not base.endswith("/api/v1/agents"):
raise RuntimeError("FLYMYAI_AGENTS_API_ROOT must end in /api/v1/agents")
deployment_id = os.environ["FLYMYAI_DEPLOYMENT_ID"]
customer_id = "customer_42" # derive this from your authenticated session
headers = {"X-API-KEY": os.environ["FLYMYAI_API_KEY"]}
run_key = os.environ["PERSISTED_DEPLOYMENT_RUN_KEY"]
# Load a key reserved with the canonical request in durable storage before this process.
response = requests.post(
f"{base}/deployments/{deployment_id}/run/",
headers={**headers, "Idempotency-Key": run_key},
json={
"external_user_id": customer_id,
"variables": {"since": "2026-08-15"},
},
timeout=30,
)
response.raise_for_status()
execution_id = response.json()["id"]
since = None
for _ in range(150):
poll = requests.get(
f"{base}/executions/{execution_id}/status/",
headers=headers,
params={"since": since} if since else None,
timeout=30,
)
poll.raise_for_status()
state = poll.json()
since = state.get("last_step_id") or since
if state["is_settled"]:
if state["status"] != "completed":
raise RuntimeError(state.get("error") or state["status"])
result = state["result"]
break
time.sleep(2)
else:
raise TimeoutError(execution_id)
print(result)
The example omits mapping fields, so FlyMyAI-managed bindings apply. For a customer-managed named mapping, add both saved values to the same canonical request before reserving its idempotency key:
request_body = {
"external_user_id": customer_id,
"variables": {"since": "2026-08-15"},
"resource_set_id": customer_mapping_id,
"resource_set_revision": customer_mapping_revision,
}
For a one-off explicit mapping, send connections instead of
resource_set_id and resource_set_revision. Every referenced connection must
belong to the resolved principal and satisfy the frozen slot contract. Each
value is either one integration-connection public UUID or an array of UUIDs for
a pooled slot:
request_body = {
"external_user_id": customer_id,
"variables": {"since": "2026-08-15"},
"connections": {
"support_mailbox": support_connection_id,
"archive_mailbox": [archive_connection_id],
},
}
Use only slots from the published access requirements, honor cardinality, and include every required slot when the request is intended to be a complete explicit mapping. An omitted slot is not an explicit empty value: current runtime behavior continues to its saved binding for that slot. Every supplied UUID is checked against the same external principal. There is no fallback from a customer run to an owner's direct connection, and the body carries IDs only, never provider credentials.
Use a new idempotency key for a new logical run. Reuse the same key only for an identical request through the documented replay contract. Never automatically repeat a deployment write after a timeout, lost response, or other ambiguous dispatch. Persist the unknown outcome and reconcile it before any new dispatch.
The endpoint starts work asynchronously. In a request handler, persist the execution ID and poll from a background job instead of keeping the customer HTTP request open.
Embedded customer runs currently accept variables and supported connection
selection, but not per-run agent_file_external_ids. Sending that field returns
HTTP 400. Do not publish a file-input contract that depends on customer file
attachments until that embedded capability is available.
Prove customer isolation before release
Make this an early blocking release gate. Use one published deployment and two controlled customer IDs. A trusted test identity store may mint synthetic IDs; neither a model prompt nor a browser request may choose them. Normal product IDs must come from authenticated server sessions. Each logical run gets its own persisted idempotency key; an immediate identical replay uses that same key and must return the same execution ID.
After both runs have been accepted, query the owner-only principal list:
def principal_for(customer_id):
response = requests.get(
f"{base}/external-principals/",
headers=headers,
params={
"deployment": deployment_id,
"external_user_id": customer_id,
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
items = payload.get("results", []) if isinstance(payload, dict) else payload
if len(items) != 1:
raise RuntimeError(
f"Expected one principal for {customer_id}, got {len(items)}"
)
return items[0]
principal_a = principal_for("release_probe_customer_a")
principal_b = principal_for("release_probe_customer_b")
assert principal_a["deployment"] == deployment_id
assert principal_b["deployment"] == deployment_id
assert principal_a["external_user_id"] != principal_b["external_user_id"]
assert principal_a["public_id"] != principal_b["public_id"]
Also fetch exactly one runtime snapshot for each execution. Assert that both
snapshots reference the same deployment and immutable agent version, each
snapshot's billing_user equals /agents/me/ field user_id, and their
execution and external-principal IDs differ. Assert that each idempotent replay
returned its original execution ID and that both price records are readable
with the same builder key. This proves one agent and one billed owner serve two
isolated product principals. For connectors, inspect access separately for
both IDs and verify that no connection or binding UUID crosses the principal
boundary.
Use a connectionless supported adapter for this deterministic topology gate.
Run provider-specific connection tests separately so an OAuth prompt cannot
hide an identity regression. In each provider test, also query
/tool-operations/?execution=<execution_id> and match the recorded connection
IDs to that customer's access response. An empty connectionless binding set
proves principal separation, but it does not prove provider connection
selection.
Run the deterministic lifecycle test and a separate fresh-assistant test as
early blocking gates before broad regression suites. The fresh assistant must
start in an empty directory and receive only the exact candidate skill.md as
FlyMyAI documentation plus a normal product request. Preserve the guide bytes
and SHA-256, prompt, structured MCP transcript, operation ledger, REST evidence,
created IDs, and cleanup result. See the exact commands in
Step 7 of the lifecycle guide.
Cleanup only resources carrying the unique test label. Revoke test connections, disable both external principals, archive the deployment, and soft-archive the agent. Immutable versions, snapshots, prices, and other audit rows remain as release evidence. A successful revoke can leave a revoked connection audit row and an empty binding row; require terminal revoked/unbound state rather than physical deletion. Incomplete cleanup blocks release.
Handle missing connections correctly
A missing, expired, or invalid binding currently returns HTTP 400 with a
connections validation error. It does not return a typed
connection_required result or an inline connect URL.
Handle the response by status and body:
- If the body has a
connectionserror, queryaccessfor the same customer. - Create a new
connect-sessiononly for the missing or expired slot. - Send that customer the new redirect URL and wait for authorization.
- If the request used a named resource set, reload its current revision and verify that the newly active exact connection is a member.
- Retry the original run with the same logical inputs after authorization.
Do not convert every non-success response into "connect an account". An invalid variable, inactive deployment, invalid API key, rate limit, or server failure is a different problem and must retain its original error.
Multiple slots and accounts
Slots describe roles in the frozen workflow, not customers.
- One
mailboxslot is filled independently by every customer. - If the workflow needs two different mailboxes, declare two roles such as
support_mailboxandsales_mailbox. - A slot's
cardinalitytells you whether it accepts exactly one, zero or one, or multiple connections. - Explicit per-run connection overrides must reference connection UUIDs that belong to the same external principal.
- A named customer mapping can reuse those exact connections across runs while preserving a revision for optimistic concurrency.
The uniqueness boundary is the deployment plus external_user_id plus slot.
Customer A's mailbox binding cannot satisfy Customer B's run.
Billing and your product ledger
FlyMyAI stores the deployment owner as the run's billing user. The customer ID is available to your application because you sent it, but FlyMyAI does not automatically charge that customer for you.
Persist at least:
external_user_id
deployment_id
mapping_mode
resource_set_id and resource_set_revision, when customer-managed
execution_id
idempotency_key
After settlement, fetch the execution price and apply your own quota, credits, markup, subscription, or invoice rules.
Publish a new version without changing customer IDs
To update behavior, modify the same agent, approve a new run, and freeze it into a new immutable version. Stage that version as the existing deployment's candidate, run preflight again, and publish it.
The deployment ID and each customer's external ID stay the same. Existing bindings remain customer-specific; the new version can reuse them when its slot contract is compatible.
Use the exact PATCH, preflight, and publish sequence in Step 6 of the lifecycle guide.
Related guides
- Create, Freeze, and Embed One Agent - the complete lifecycle and both integration branches
- Call Your Agent from Your Product - REST execution and polling details
- Inputs, Outputs & Variables - define the runtime schema before freezing