# Per-customer MCP access

> Publish one frozen FlyMyAI agent for many product users while each user keeps isolated third-party service connections selected by external_user_id.

This guide is the customer-connection branch of
[Create, Freeze, and Embed One Agent](https://docs.flymy.ai/agents/guides/serverless-agents.md). Start
there if you have not yet created, tested, and frozen the agent.

The invariant is:

```text
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:

```bash
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:

```text
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:

```bash
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:

```bash
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}`:

```bash
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"}'
```

> **Caution: 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:

| 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](https://docs.flymy.ai/agents/guides/mcp-resource-sets.md#product-customer-mapping-modes).
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:

```bash
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.

```bash
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:

```python
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:

```python
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:

```python
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:

```python
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](https://docs.flymy.ai/agents/guides/serverless-agents.md#step-7---run-the-clean-agent-release-gate).

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:

```text
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](https://docs.flymy.ai/agents/guides/serverless-agents.md#step-6---release-a-change-without-cloning-the-agent).

## Related guides

- [Create, Freeze, and Embed One Agent](https://docs.flymy.ai/agents/guides/serverless-agents.md) - the
  complete lifecycle and both integration branches
- [Call Your Agent from Your Product](https://docs.flymy.ai/agents/guides/call-from-your-app.md) - REST
  execution and polling details
- [Inputs, Outputs & Variables](https://docs.flymy.ai/agents/concepts/inputs.md) - define the runtime
  schema before freezing
