Embedded and resale builder reference
This page holds the complete builder sequence from the public agent guide at flymy.ai/skill.md, which keeps only its essence. The step-by-step walkthrough is Create, Freeze & Embed, the customer identity model is Per-customer MCP Access, and named customer mappings are covered in Multiple MCP Accounts & Resource Sets.
Deployment runs require a nonblank printable ASCII Idempotency-Key of 1-255
characters with no leading or trailing spaces. Neither gateway nor backend
generates a fallback: reserve the key with the canonical request before the
first dispatch and reuse it only for the identical request. Leading or trailing
spaces, non-ASCII characters, and control characters fail locally.
Choose the mode
| Mode | FlyMyAI identity and key | Service connections | Billing |
|---|---|---|---|
| Personal | The account connected to this assistant | That account's services | That FlyMyAI account |
| Embedded or resale | One builder key on the builder backend | Each product user's own services | FlyMyAI charges the builder; the builder charges its users |
Never ask every embedded end user to create a FlyMyAI account or paste a FlyMyAI key.
After freeze, choose one integration branch
- Owner/private - call
POST /api/v1/agents/compilations/{compilation_id}/run-instruction/with a persistedIdempotency-Key; use the same key only to replay the identical request. - Product customers with their own connected service accounts - publish the
same frozen version as one stable deployment, create hosted connection links
per required slot, and call
POST /api/v1/agents/deployments/{deployment_id}/run/withexternal_user_id.
external_user_id selects that product customer's isolated principal and
supported connector bindings. It does not switch the FlyMyAI billing identity:
FlyMyAI charges the deployment owner, and the builder applies its own customer
billing, quota, credits, or markup.
Be precise about the boundary: the normal owner Agents MCP surface can create,
run, refine, freeze, and owner-test agents. Deployment publish and customer
connect-session remain Agents REST operations. A separately provisioned
customer-bound MCP surface can run one already-published deployment for one
customer, but only after trusted server configuration binds the deployment,
external_user_id, and optional mapping. Those authority values are never
model-call arguments. Do not confuse customer identity with the OAuth user_id
of an owner MCP session, and do not invent client.deployments calls unless the
installed SDK version actually exposes them.
If embedded preflight rejects a toolkit, keep the logical worker whole and report the unsupported requirement. Do not silently turn it into two agents or bypass preflight.
If a request already says the agent will be embedded in a product or names product customer IDs, take the customer-deployment branch directly. Do not ask whether to split the workflow or create one agent per customer.
For the owner/private branch, the exact REST schedule update for an existing compilation is:
curl -fsS --max-time 30 -X PATCH -H "X-API-KEY: $FLYMYAI_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"cron_schedule":"0 9 * * 1-5","timezone":"UTC","schedule_variables":{}}' \
"$FLYMYAI_AGENTS_API_ROOT/compilations/$COMPILATION_ID/"
# Clear it through the same route with {"cron_schedule":""}.
One builder key, no end-user keys
The builder wires FlyMyAI once:
product client -> authenticated builder backend -> stable FlyMyAI deployment
-> principal unique to (deployment, external_user_id) -> that principal's mapping
external_user_id is the builder's stable, opaque, non-secret ID, at most 255
characters, and must not start with the reserved flymyai-owner- prefix.
FlyMyAI creates the principal lazily. End users do not sign up for FlyMyAI and
never see the builder key. When a connector needs authorization, the builder
redirects that user through a short-lived hosted connection URL. Runtime lookup
rechecks every saved, named, or explicit connection against the same principal.
Rules:
- Keep
FLYMYAI_API_KEYonly in a server secret manager. Never expose it to clients, URLs, logs, or chat. - Before an MCP-authored agent crosses to deployment REST, call MCP
whoamiandGET /api/v1/agents/me/with the builder key. Require the sameuser_id; otherwise stop, because the REST key cannot publish another owner's agent. - Derive
external_user_idfrom the authenticated server session. Do not accept an arbitrary client-supplied ID or use an email or secret. - Reserve and persist a stable
Idempotency-Keywith the canonical deployment-run request before dispatch. Reuse it only for the identical request through the documented replay contract. Never automatically repeat a write after a timeout, lost response, or other ambiguous dispatch - persist the unknown outcome and reconcile it first. A replay after the first response returns the same execution while the idempotency record exists; successful records are eligible for cleanup after 2 days. Reuse after cleanup can create a new execution. Changed-body reuse returns 409; a concurrent replay can return 409 while the first is still committing. - FlyMyAI bills the deployment owner. Automatic onward charging, billing passthrough, and per-user cost rollup do not exist today.
- Persist
(external_user_id, deployment_id, mapping_mode, resource_set_id, resource_set_revision, execution_id, idempotency_key), omitting mapping fields only when that mode does not use them. Fetch the settled execution price and apply your own quota, credits, markup, or invoice. - Call Models API separately from the builder backend and map its request ID into the same ledger.
Choose the customer mapping mode
external_user_id selects the external principal only. It does not select an
account or mapping.
- FlyMyAI-managed binding - omit both
resource_set_idandconnections; savedConnectionBindingrows apply. - Customer-managed named mapping - store a principal resource-set
public_idandrevisionin the builder backend, then send bothresource_set_idandresource_set_revisionon every run. The backend still accepts an omitted revision for compatibility, but the safe public workflow never omits it. - One-off explicit mapping - send
connectionskeyed by frozen slot.
resource_set_id and connections are mutually exclusive. A named mapping
can contain only exact integration_connection public UUIDs owned by the
resolved principal. FlyMyAI revalidates authority, slots, status, and revision
at the snapshot boundary. The opaque mapping ID never exposes credentials or
changes the deployment owner's billing identity.
The exact one-off shape maps each frozen slot to either one connection UUID or an array of connection UUIDs:
{
"external_user_id":"customer_42",
"variables":{},
"connections":{
"support_mailbox":"11111111-1111-4111-8111-111111111111",
"compliance_mailbox":["22222222-2222-4222-8222-222222222222"]
}
}
Every UUID must be an active integration_connection owned by the resolved
principal. Unknown slots, duplicate UUIDs, the wrong toolkit, cross-principal
IDs, and cardinality violations fail before provider dispatch. Omitted slots
currently continue through saved bindings, so include every required frozen
slot when the canonical request must own the complete mapping. Owner
user_mcp_tool IDs are never valid here, and a customer principal never falls
back to the owner's authoring connection.
Optional customer-bound MCP runtime
Use the owner MCP surface to author, test, and freeze. Use Agents REST to publish and bootstrap connections. After that, a product backend may expose a published deployment through a separately provisioned customer MCP process. Current safe customer mode has 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
The product backend derives external_user_id from its authenticated session
and places it in trusted process provisioning. It is never a model-call
argument, client header, query parameter, or value copied from chat. Use the
resource-set pair for a customer-managed named mapping, omit both resource-set
variables for saved FlyMyAI bindings, or set
FLYMYAI_MCP_CONNECTIONS_JSON for one exact process-managed mapping.
Connect the customer-facing MCP client with
Authorization: Bearer <customer-edge-token>, not the owner API key. The
surface exposes only:
started = run_bound_deployment({
"variables":{},
"operation_key":"customer-logical-run-<uuid>"
})
page = get_bound_deployment_run({"run_handle":started.run_handle})
Pass next_since while has_more=true and stop only when
poll_complete=true. The opaque handle is bound to the configured deployment,
customer, and owner credential. To serve another product user, route to a
separately trusted binding. Two bindings may use the same deployment, but they
must produce different ExternalPrincipal and execution records while keeping
the same agent, version, deployment, and billed owner.
Publish a frozen agent
AGENT_ID and COMPILATION_ID must identify the one requested agent and its
one accepted instruction freeze, or the one disposable fixture agent only when
a release maintainer deliberately ran the
connectionless platform fixture. Never
freeze the same accepted execution again merely to enter this publish sequence.
FLYMYAI_API_KEY must already be exported by the builder's secret manager. Do
not edit the agent between the accepted freeze and version selection. If a
freeze response was lost, do not POST it again: reconcile with the bounded
compilation list filtered by the known execution ID and stop if the outcome is
not unique.
set -euo pipefail
: "${FLYMYAI_API_KEY:?Export the builder key in this server process}"
: "${AGENT_ID:?Set from create_agent}"
: "${COMPILATION_ID:?Set from the one accepted instruction freeze}"
: "${OPERATION_LABEL:?Reuse the unique audit label from agent creation}"
: "${FLYMYAI_AGENTS_API_ROOT:?Set the exact Agents API root ending in /api/v1/agents}"
A="${FLYMYAI_AGENTS_API_ROOT%/}"
auth=(-H "X-API-KEY: $FLYMYAI_API_KEY")
for attempt in $(seq 1 1350); do
compilation=$(curl -fsS --max-time 30 "${auth[@]}" \
"$A/compilations/$COMPILATION_ID/")
status=$(jq -r '.status' <<<"$compilation")
error=$(jq -r '.error // empty' <<<"$compilation")
if test -n "$error"; then jq '{status,error}' <<<"$compilation"; exit 1; fi
case "$status" in
compiled|completed) break ;;
failed) jq '{status,error}' <<<"$compilation"; exit 1 ;;
esac
sleep 2
done
case "$status" in compiled|completed) ;; *) exit 1 ;; esac
VERSION_ID=
for attempt in $(seq 1 1350); do
next_url="$A/versions/?agent_task=$AGENT_ID"
version_ids='[]'
for page in $(seq 1 100); do
case "$next_url" in "$A/versions/"*) ;; *) exit 1 ;; esac
versions=$(curl -fsS --max-time 30 "${auth[@]}" "$next_url")
page_ids=$(jq -c --argjson c "$COMPILATION_ID" \
'[.results[]|select(.source_compilation==$c)|.public_id]' <<<"$versions")
version_ids=$(jq -cn --argjson prior "$version_ids" \
--argjson current "$page_ids" '$prior+$current')
next_url=$(jq -r '.next // empty' <<<"$versions")
if test -z "$next_url"; then break; fi
if test "$page" = 100; then exit 1; fi
done
case "$(jq -r 'length' <<<"$version_ids")" in
0) sleep 2 ;;
1) VERSION_ID=$(jq -r '.[0]' <<<"$version_ids"); break ;;
*) echo "Multiple versions materialized for compilation $COMPILATION_ID" >&2; exit 1 ;;
esac
done
test -n "$VERSION_ID"
# The versions route is cursor-paginated. The loop follows at most 100
# same-origin pages and never sends the builder key to an untrusted next URL.
deployment=$(curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg a "$AGENT_ID" --arg v "$VERSION_ID" --arg label "$OPERATION_LABEL" \
'{agent_task:$a,candidate_version:$v,name:("Production ["+$label+"]"),status:"draft",publish_mode:"embedded"}')" \
"$A/deployments/")
DEPLOYMENT_ID=$(jq -er '.public_id' <<<"$deployment")
access=$(curl -fsS --max-time 30 "${auth[@]}" \
"$A/deployments/$DEPLOYMENT_ID/access/")
jq '[.requirements[]|{slot,connection_required,hosted_setup_supported}]' <<<"$access"
jq -e 'all(.requirements[];(.connection_required|not) or .hosted_setup_supported)' \
<<<"$access" >/dev/null
curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' --data '{"publish_mode":"embedded"}' \
"$A/deployments/$DEPLOYMENT_ID/preflight/" | jq -e '.ready==true' >/dev/null
curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' --data '{"publish_mode":"embedded"}' \
"$A/deployments/$DEPLOYMENT_ID/publish/" | jq '{public_id,status,active_version}'
The stable deployment ID remains your endpoint when a newer immutable version is published. A version pins instruction, schemas, internal LLM, effort, tool manifest, and requirements.
Upgrade and rollback that same deployment - never create a deployment per
release. PATCH its candidate_version, run preflight, then publish. To roll
back, stage the prior immutable version on the same ID and repeat the same two
checks:
curl -fsS --max-time 30 -X PATCH "${auth[@]}" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg v "$NEXT_VERSION_ID" '{candidate_version:$v}')" \
"$A/deployments/$DEPLOYMENT_ID/"
curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' --data '{"publish_mode":"embedded"}' \
"$A/deployments/$DEPLOYMENT_ID/preflight/" | jq -e '.ready==true' >/dev/null
curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' --data '{"publish_mode":"embedded"}' \
"$A/deployments/$DEPLOYMENT_ID/publish/" >/dev/null
Preflight currently accepts only supported catalog adapters. It rejects arbitrary custom MCP servers, mutable skills, unsupported adapters, and raw media-model tools inside an embedded agent. Do not bypass a failed preflight. Call raw Models API separately from your backend.
Connect one user's service account
For each connection_required slot, use the exact slot returned by access:
The deployment access GET is read-only. It can return requirements and
existing customer state, but it never creates an ExternalPrincipal. For a
new external user, the connect-session POST below is the first mutating
bootstrap: it resolves or creates the principal and then creates the hosted
authorization session.
: "${EXTERNAL_USER_ID:?Derive from the authenticated product user}"
: "${SLOT:?Use a connection_required slot from access}"
session=$(curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg u "$EXTERNAL_USER_ID" --arg s "$SLOT" \
'{external_user_id:$u,slot:$s}')" \
"$A/deployments/$DEPLOYMENT_ID/connect-session/")
jq '{redirect_url,expires_at}' <<<"$session"
Redirect that authenticated user to redirect_url. It expires after 15 minutes and is single-use. The user authorizes the third-party service, not FlyMyAI. There is no reseller success webhook today. Poll access with backoff until each required slot has the required cardinality of active, unexpired connections, not merely a binding row:
Treat connect-session as a single-dispatch write. Persist its canonical
deployment, customer, slot, and audit label before calling. It has no caller
idempotency key. After a timeout or lost response, do not immediately create a
second session; reconcile through access and the known 15-minute window, and
surface an unknown outcome if uniqueness cannot be proven.
curl -fsS --max-time 30 -G "${auth[@]}" \
--data-urlencode "external_user_id=$EXTERNAL_USER_ID" \
"$A/deployments/$DEPLOYMENT_ID/access/" \
| jq '{requirements,connections:[.connections[]|{public_id,principal,toolkit_slug,alias,status,expires_at}],bindings}'
The run call is authoritative. If readiness changed, preserve and handle its HTTP 400 connections error instead of assuming a stale binding is usable.
Keep the full access response on the authenticated builder backend. Send only
the hosted redirect_url to the customer, not owner policy, connection, or
binding metadata.
Optional customer-managed named mapping
After the exact hosted connections are active, resolve the principal and create
one principal-scoped resource set. Use exact active connection public_id
values from that customer's access response:
: "${CUSTOMER_CONNECTION_ID:?Exact active connection public_id from access}"
: "${CUSTOMER_RESOURCE_SET_CREATE_KEY:?Persist one key for this exact customer mapping create}"
principals=$(curl -fsS --max-time 30 -G "${auth[@]}" \
--data-urlencode "deployment=$DEPLOYMENT_ID" \
--data-urlencode "external_user_id=$EXTERNAL_USER_ID" \
"$A/external-principals/")
PRINCIPAL_ID=$(jq -er '
(.results // .) as $rows |
if ($rows|length)==1 then $rows[0].public_id
else error("expected exactly one external principal") end
' <<<"$principals")
mapping=$(curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H "Idempotency-Key: $CUSTOMER_RESOURCE_SET_CREATE_KEY" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg p "$PRINCIPAL_ID" '{
name:"Primary customer mapping",
management_mode:"customer",
principal_id:$p
}')" \
"$A/mcp-resource-sets/")
CUSTOMER_RESOURCE_SET_ID=$(jq -er '.public_id' <<<"$mapping")
CUSTOMER_RESOURCE_SET_REVISION=$(jq -er '.revision' <<<"$mapping")
mapping=$(curl -fsS --max-time 30 -X POST "${auth[@]}" \
-H 'Content-Type: application/json' \
--data "$(jq -nc \
--arg connection "$CUSTOMER_CONNECTION_ID" \
--arg slot "$SLOT" \
--argjson revision "$CUSTOMER_RESOURCE_SET_REVISION" '{
expected_revision:$revision,
members:[{
resource_type:"integration_connection",
resource_id:$connection,
slot:$slot,
allowed_actions:[],
position:0
}]
}')" \
"$A/mcp-resource-sets/$CUSTOMER_RESOURCE_SET_ID/replace-members/")
CUSTOMER_RESOURCE_SET_REVISION=$(jq -er '.revision' <<<"$mapping")
For several slots or accounts, send the complete desired member array in one replacement. A set can reference only connections owned by that exact principal. Persist the returned mapping ID and revision in the builder backend.
Run and meter one user
This released HTTP contract keeps the key server-side and uses the slim progress
route. Store the canonical variables object alongside the idempotency key;
AGENT_VARIABLES_JSON={} is valid only when the frozen input schema has no
required fields:
import json, os, time, uuid
import httpx
base = os.environ["FLYMYAI_AGENTS_API_ROOT"].rstrip("/")
if not base.endswith("/api/v1/agents"):
raise ValueError("FLYMYAI_AGENTS_API_ROOT must end in /api/v1/agents")
deployment = os.environ["FLYMYAI_DEPLOYMENT_ID"]
user_id = os.environ["PRODUCT_USER_ID"]
idem = os.environ["PERSISTED_DEPLOYMENT_RUN_KEY"]
variables = json.loads(os.environ.get("AGENT_VARIABLES_JSON", "{}"))
if not isinstance(variables, dict):
raise ValueError("AGENT_VARIABLES_JSON must contain a JSON object")
request_body = {"external_user_id": user_id, "variables": variables}
mapping_id = os.environ.get("CUSTOMER_MCP_RESOURCE_SET_ID")
mapping_revision = os.environ.get("CUSTOMER_MCP_RESOURCE_SET_REVISION")
connections_json = os.environ.get("CUSTOMER_CONNECTIONS_JSON")
if mapping_id and connections_json:
raise ValueError("Choose resource_set_id or connections, not both")
if mapping_revision and not mapping_id:
raise ValueError("A resource-set revision requires its mapping id")
if mapping_id:
if not mapping_revision:
raise ValueError("The public named-mapping workflow requires its revision")
request_body["resource_set_id"] = mapping_id
request_body["resource_set_revision"] = int(mapping_revision)
if request_body["resource_set_revision"] < 1:
raise ValueError("The mapping revision must be positive")
elif connections_json:
connections = json.loads(connections_json)
if not isinstance(connections, dict):
raise ValueError("CUSTOMER_CONNECTIONS_JSON must contain a JSON object")
if len(connections) > 100:
raise ValueError("CUSTOMER_CONNECTIONS_JSON exceeds 100 slots")
for slot, selected in connections.items():
if not isinstance(slot, str) or not slot:
raise ValueError("Every connection mapping key must be a frozen slot")
ids = [selected] if isinstance(selected, str) else selected
if not isinstance(ids, list) or not ids or len(ids) > 25:
raise ValueError(f"Slot {slot} must select 1-25 connection UUIDs")
if any(not isinstance(value, str) for value in ids):
raise ValueError(f"Slot {slot} connection IDs must be strings")
for value in ids:
uuid.UUID(value)
request_body["connections"] = connections
with httpx.Client(headers={"X-API-KEY": os.environ["FLYMYAI_API_KEY"]},
timeout=httpx.Timeout(30.0, connect=10.0)) as client:
response = client.post(f"{base}/deployments/{deployment}/run/",
headers={"Idempotency-Key": idem},
json=request_body)
response.raise_for_status()
execution = response.json()["id"]
retry = client.post(f"{base}/deployments/{deployment}/run/",
headers={"Idempotency-Key": idem},
json=request_body)
retry.raise_for_status()
if retry.json()["id"] != execution:
raise RuntimeError("Idempotency replay created another execution")
since = None
for _ in range(150):
poll = client.get(f"{base}/executions/{execution}/status/",
params={"since": since} if since else None)
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)
price = client.get(f"{base}/executions/{execution}/prices/")
price.raise_for_status()
print({"execution_id": execution, "result": result,
"total_price": price.json()["total_price"]})
The price response has id, tool_calls, llm_usage, and decimal-string total_price. It has no external user field, so join it through your ledger. Do not use unpaginated /executions/prices/ for request-time rollups.
SDK namespaces
This candidate guide is aligned to flymyai==1.2.0rc5. That candidate exposes
client.versions, client.deployments, client.mcp_resource_sets, and
client.agent_groups in both sync and async clients. Capability-detect the
installed SDK before using those namespaces. An older installed package may
require the equivalent REST routes; never invent a namespace that is absent.