Skip to main content

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:

IdentityPurpose
Builder X-API-KEYOwns the agent and deployment, authorizes backend calls, and is billed by FlyMyAI
external_user_idSelects 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"}'
Supported adapters only

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:

ModeDeployment run fieldsUse when
FlyMyAI-managedOmit both resource_set_id and connectionsHosted setup and saved ConnectionBinding rows are the source of truth
Customer-managed named mappingSend resource_set_id and its saved resource_set_revisionYour backend stores a stable opaque mapping ID for this customer
Explicit one-off mappingSend connections keyed by slotYour 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:

  1. If the body has a connections error, query access for the same customer.
  2. Create a new connect-session only for the missing or expired slot.
  3. Send that customer the new redirect URL and wait for authorization.
  4. If the request used a named resource set, reload its current revision and verify that the newly active exact connection is a member.
  5. 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 mailbox slot is filled independently by every customer.
  • If the workflow needs two different mailboxes, declare two roles such as support_mailbox and sales_mailbox.
  • A slot's cardinality tells 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.