Skip to main content

Create, freeze, and embed one agent

The production pattern is deliberately simple:

create one agent -> test live runs -> freeze one accepted run -> integrate it
|-> private owner run
|-> customer deployment

The agent stays one logical worker throughout this lifecycle. Do not create a separate agent for every input, customer, or MCP connection. Runtime variables change the input. external_user_id changes which customer's supported MCP connections are used. A new freeze changes the version.

If the worker needs several accounts of the same connector type, create exact connection aliases, organize their public IDs into an MCP resource set, and grant that set to this same agent. See Multiple MCP Accounts and Resource Sets. A new account is not a reason to clone or split the worker.

This guide covers the complete path:

  1. Create one agent in the web app or through the FlyMyAI MCP gateway.
  2. Run and refine that same agent until one execution is correct.
  3. Freeze the accepted execution, with or without variables.
  4. Understand exactly what freeze returns.
  5. Embed the frozen agent in your product, either with your workspace context or with isolated per-customer MCP connections.
Give the same workflow to Claude

Use this prompt: Read https://flymy.ai/skill.md and help me create, test, freeze, and ship one FlyMyAI agent in my product. If the request already says that the agent will run inside a product or names product customer IDs, the assistant should take the customer-deployment branch directly. It should not ask whether to split the workflow or create one agent per customer. The guide tells it which authoring steps use MCP and which customer-deployment steps use REST.

One agent, two identities

Your FlyMyAI API key always belongs to the builder and stays on your backend. FlyMyAI bills that builder account. In a customer deployment, external_user_id is only the connection-scope identity: it selects that customer's authorized services. It does not create a FlyMyAI user, move billing, or expose your key to the customer.

First, separate the two meanings of MCP

MCP appears in two different parts of this flow:

MCP useWhat it does
Authoring through MCPClaude, Codex, Cursor, or another MCP client uses create_agent, run_agent, and freeze_agent to build the agent.
MCP inside the frozen agentThe running agent calls Gmail, Slack, Notion, a CRM, or another attached connector. In a customer deployment, external_user_id selects the customer's isolated connection.

You can author through the web app and still use customer MCP connections at runtime. You can also author through MCP and ship an agent with no customer connections at all. These choices are independent.

Within the frozen agent, a toolkit definition and an authenticated connection are also different identities. An owner MCP resource set can grant several exact accounts or custom servers to one agent. If several granted accounts can execute the same action, the runtime schema requires the exact _flymyai_connection public UUID instead of choosing a row by alias or order.

Step 1 - define the whole worker

Before creating anything, define five things:

  • Name - what this worker is called.
  • Goal - the complete job from input to final result.
  • Tools - only the services the worker needs.
  • Inputs - values that change between runs, if any.
  • Output - the stable result shape your product will consume.

For example, one reusable inbox agent might be defined as:

Name: Customer Inbox Brief
Goal: Read messages received since {{ since }}, group them by urgency, and
return a concise brief with action items.
Input: since (required string)
Output: summary (string), action_items (array of strings)
Tool: Gmail

That is one agent. since does not require another agent, and each customer's Gmail account does not require another agent.

Route A - create it in the web app

  1. Open app.flymy.ai/agents and select New Agent.
  2. Describe the complete goal in chat.
  3. Attach the required tools under Creation tools.
  4. If inputs change between runs, add them under Variables before freezing.
  5. Define an output schema when your product needs predictable JSON fields.

Keep refining this same agent chat. Do not start a second agent just because the first run needs a correction.

Describing one complete agent in the composer

Route B - create it from Claude or another MCP client

Connect the FlyMyAI gateway once:

export FLYMYAI_MCP_URL=https://mcp-agents.flymy.ai/mcp
: "${FLYMYAI_MCP_URL:?Set the exact MCP endpoint}"
claude mcp add --transport http flymyai "$FLYMYAI_MCP_URL"

That value is the released production endpoint. A candidate run must receive its exact MCP URL and must not fall back to production. If MCP tools/list or another bounded read-only probe is unavailable, stop the MCP path and recover the intended connection before claiming an agent was created.

Then give the assistant a normal product request. For example:

Create one reusable FlyMyAI agent called Customer Inbox Brief. It should read Gmail messages received since a required since input, group them by urgency, and return JSON with summary and action_items. Attach the Gmail tool, run it once with a real input, and show me the result. Keep refining this same agent. After I approve the run, freeze it and give me the integration code.

The assistant maps that request to the same lifecycle:

Lifecycle stepMCP tool
Find and attach toolssearch_tools, add_tool
Create the single agentcreate_agent
Test itrun_agent, then poll get_run
Refine itupdate_agent or append_message
Freeze the accepted runfreeze_agent, then poll get_compilation
Test the frozen versionrun_frozen, then poll get_run

The returned agent_id is the identity of the worker. Keep it. Later runs and versions belong to that same agent.

Before a connector write, discover the exact action with search_tools and inspect its request schema with the read-only get_tool_schemas path. Never call a create, update, send, append, batch-write, or delete action with empty or guessed arguments to learn its schema. For direct catalog calls, reserve one stable operation_key before dispatch and reuse it only for the identical logical request. Run OAuth and Composio actions inside the agent unless the gateway explicitly documents a safe direct-call contract. If schema discovery returns not_found, stop the direct-call path or use the agent runtime - do not probe the write.

Give every run a durable caller-owned key

MCP run_agent and run_frozen require a nonblank printable ASCII operation_key of 1-255 characters with no leading or trailing spaces. REST live-agent, frozen-compilation, and deployment runs require a nonblank Idempotency-Key header with the same maximum. No surface generates a fallback. Reserve the key with the canonical request before dispatch and reuse it only for an identical replay. Other writes such as create_agent, instruction freeze, and deployment creation still require a unique audit label and lost-response reconciliation rather than blind retry.

Step 2 - prove one live run

Run the agent with a realistic input and wait until it settles. Review both the result and the tool calls.

For the example above:

run_agent({
"agent_id": "<agent UUID>",
"variables": {"since": "2026-08-01"},
"operation_key": "daily-brief-source-<uuid>"
})

If the result is wrong, update the same agent and run it again. Freeze only a completed execution whose behavior you accept. The execution ID identifies the specific successful run that will be distilled.

Step 3 - freeze with or without variables

Freeze takes the accepted execution ID. You do not pass runtime variable values to the freeze call itself.

The difference between the two modes is defined on the agent before the successful run:

ModeAgent definition before freezeFuture run call
Without variablesFixed goal, no input_schema, no {{ placeholders }}Omit variables or send an empty body
With variablesinput_schema plus matching {{ placeholders }} in the goalSend values that validate against that schema

Without variables

Use this when every run performs the same fixed job:

compilation = client.agents.compile_from_run(accepted_run.id, timeout=120)

result = client.compilations.run_instruction_and_wait(
compilation.id,
idempotency_key="fixed-worker-frozen-test-<uuid>",
)
print(result.output)

With variables

Define the schema on the same agent before the run you freeze:

agent = client.agents.create(
name="Customer Inbox Brief",
goal=(
"Read messages received since {{ since }}, group them by urgency, "
"and return a concise brief with action items."
),
tools=[gmail_tool_id],
input_schema={
"type": "object",
"properties": {
"since": {
"type": "string",
"description": "Beginning of the reporting period",
}
},
"required": ["since"],
"additionalProperties": False,
},
output_schema={
"type": "object",
"properties": {
"summary": {"type": "string"},
"action_items": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["summary", "action_items"],
"additionalProperties": False,
},
)

run = client.runs.create(
agent_id=agent.id,
variables={"since": "2026-08-01"},
idempotency_key="customer-inbox-source-<uuid>",
)
accepted_run = client.runs.wait(run.id, timeout=600)
if accepted_run.status != "completed":
raise RuntimeError(accepted_run.error or accepted_run.status)

compilation = client.agents.compile_from_run(accepted_run.id, timeout=600)

result = client.compilations.run_instruction_and_wait(
compilation.id,
variables={"since": "2026-08-15"},
idempotency_key="customer-inbox-frozen-<uuid>",
timeout=600,
)
print(result.output)

The schema is the runtime contract. Missing required fields, extra forbidden fields, and wrong types fail validation before the frozen execution starts.

In the web app, the equivalent flow is Run it -> For developers -> Variables -> Compile.

Declare variables, then compile the accepted run

Step 4 - know what freeze returns

The recommended instruction-freeze flow returns a compilation resource:

FieldMeaning
idInteger compilation ID used by run-instruction and run_frozen
statusBefore the first frozen run: pending or compiling until compiled, or failed. After a frozen run starts, this projected field can be running, completed, or failed.
instruction_mdThe reviewed Markdown instruction distilled from the accepted run
errorFreeze error when compilation fails

The freeze call can return before compilation finishes. Poll get_compilation or use compile_from_run, which waits for the initial compiled or failed state. If you test the frozen artifact afterward, completed means its latest frozen execution completed - it is not a reason to freeze again. Treat both compiled and completed as publish-ready, failed as terminal, and running as a state to keep polling with a bound.

A compilation is an owner-editable authoring artifact: the API can PATCH its instruction_md. Do not mutate it after approval if you need a reproducible release. The immutable deployment artifact is the AgentVersion materialized from that compilation; a deployment pins that version.

The normal output is not a generated source file. The UI's Integrate step gives you a ready snippet that calls the frozen compilation ID:

Copy the integration snippet for this exact freeze

The older compile endpoint can generate script_code, but it is a deprecated replay path. For new integrations, use instruction freeze plus run-instruction so schemas, tools, and the reviewed instruction stay on the managed runtime.

Keep these identifiers in your application configuration:

AGENT_ID - the one editable worker
COMPILATION_ID - the reviewed, owner-editable freeze artifact
VERSION_ID - one immutable deployment release
DEPLOYMENT_ID - optional stable customer-facing release channel

Set one exact Agents API environment before using the HTTP examples. Released production uses the value below. A release candidate must supply its own root and must never fall back to production:

export FLYMYAI_AGENTS_API_ROOT=https://backend.flymy.ai/api/v1/agents

Step 5A - integrate without per-customer MCP

Use this path when the frozen agent has no external connector, uses a connectionless tool, or should run with the builder's own workspace context. Your backend calls the frozen compilation directly. Both the SDK and raw HTTP forms require a caller-owned idempotency key; neither generates one.

import os
from flymyai import AgentClient

client = AgentClient(api_key=os.environ["FLYMYAI_API_KEY"])

result = client.compilations.run_instruction_and_wait(
int(os.environ["FLYMYAI_COMPILATION_ID"]),
variables={"since": "2026-08-15"}, # omit for an agent without variables
idempotency_key=os.environ["PERSISTED_RUN_KEY"],
timeout=600,
)

if result.status != "completed":
raise RuntimeError(result.error or result.status)

print(result.output)

The plain HTTP start call is:

: "${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 -X POST \
"$BASE/compilations/$COMPILATION_ID/run-instruction/" \
-H "X-API-KEY: $FLYMYAI_API_KEY" \
-H "Idempotency-Key: $PERSISTED_RUN_KEY" \
-H "Content-Type: application/json" \
-d '{"variables":{"since":"2026-08-15"}}'

Reserve and persist PERSISTED_RUN_KEY with the canonical compilation ID and request body before dispatch. Reuse it only for the identical replay. A changed body with the same key returns a conflict. After a timeout or lost response, reconcile the stored operation instead of starting the run with a new key.

If the agent has an input_schema, always include a variables object. It may be empty only when the schema has no required fields. For an agent without an input schema, omit the body.

The start call returns an execution ID immediately. Poll GET /api/v1/agents/executions/{execution_id}/status/ and read result when is_settled is true. Keep the API key on your backend, never in browser code.

Step 5B - integrate with per-customer MCP

Use this path when the same frozen agent runs for many customers and each customer authorizes their own Gmail, Slack, Notion, CRM, or another supported hosted connector.

The topology remains one agent:

one agent -> one frozen version -> one stable deployment
|-> external_user_id customer_1
|-> external_user_id customer_2
|-> external_user_id customer_3

Publish, preflight, and hosted connection setup are currently REST. Customer execution can use the REST run shown below or a separately provisioned customer-bound MCP process after publish. The bound process exposes only run_bound_deployment and get_bound_deployment_run; it takes variables and an operation key, while trusted server configuration binds deployment, external_user_id, and mapping. See Per-customer MCP Access. Do not generate client.deployments examples unless the installed SDK version actually exposes that namespace.

The owner agent may already use an owner MCP resource set while it is being authored. Customer deployment mappings are separate and belong to each exact ExternalPrincipal; an owner set is never reused as a customer set.

When authoring used MCP OAuth, call MCP whoami and compare its user_id with GET $FLYMYAI_AGENTS_API_ROOT/me/ authenticated by the REST key. They must match before publication. A key owned by another builder cannot and must not publish the MCP-authored agent.

1. Materialize and publish the frozen version

After freeze reaches compiled, reuse that same COMPILATION_ID - do not freeze the accepted execution again. Without editing the agent in between, wait for the immutable version materialized from that exact compilation. Reject zero results after the bounded wait or more than one exact match. Then create one draft deployment, inspect its access requirements, run preflight, and publish it:

import os
import time
import requests
from urllib.parse import urljoin, urlparse

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")
HEADERS = {"X-API-KEY": os.environ["FLYMYAI_API_KEY"]}
AGENT_ID = os.environ["FLYMYAI_AGENT_ID"]
COMPILATION_ID = int(os.environ["FLYMYAI_COMPILATION_ID"])
OPERATION_LABEL = os.environ["FLYMYAI_OPERATION_LABEL"] # persist before writes

version_id = None
for _ in range(1350):
versions = []
page_url = f"{BASE}/versions/"
page_params = {"agent_task": AGENT_ID}
for _page in range(100):
version_response = requests.get(
page_url,
headers=HEADERS,
params=page_params,
timeout=30,
)
version_response.raise_for_status()
payload = version_response.json()
if isinstance(payload, list):
versions.extend(payload)
break
versions.extend(payload["results"])
next_url = payload.get("next")
if not next_url:
break
candidate_url = urljoin(f"{BASE}/", next_url)
if urlparse(candidate_url)[:2] != urlparse(BASE)[:2]:
raise RuntimeError("Version pagination changed origin")
page_url = candidate_url
page_params = None
else:
raise RuntimeError("Version pagination exceeded 100 pages")

matches = [version for version in versions
if version["source_compilation"] == COMPILATION_ID]
if len(matches) > 1:
raise RuntimeError("Multiple versions match the accepted compilation")
if matches:
version_id = matches[0]["public_id"]
break
time.sleep(2)
if version_id is None:
raise TimeoutError("Accepted frozen version was not materialized")

deployment_response = requests.post(
f"{BASE}/deployments/",
headers=HEADERS,
json={
"agent_task": AGENT_ID,
"candidate_version": version_id,
"name": f"Production [{OPERATION_LABEL}]",
"status": "draft",
"publish_mode": "embedded",
},
timeout=30,
)
deployment_response.raise_for_status()
deployment_id = deployment_response.json()["public_id"]

access_response = requests.get(
f"{BASE}/deployments/{deployment_id}/access/",
headers=HEADERS,
timeout=30,
)
access_response.raise_for_status()
access = access_response.json()

unsupported = [
requirement["slot"]
for requirement in access["requirements"]
if requirement["connection_required"]
and not requirement["hosted_setup_supported"]
]
if unsupported:
raise RuntimeError(f"Embedded setup is not supported for slots: {unsupported}")

preflight_response = requests.post(
f"{BASE}/deployments/{deployment_id}/preflight/",
headers=HEADERS,
json={"publish_mode": "embedded"},
timeout=30,
)
preflight_response.raise_for_status()
if not preflight_response.json()["ready"]:
raise RuntimeError("Deployment preflight is not ready")

publish_response = requests.post(
f"{BASE}/deployments/{deployment_id}/publish/",
headers=HEADERS,
json={"publish_mode": "embedded"},
timeout=30,
)
publish_response.raise_for_status()

The 45-minute bound covers the backend's full scheduled materialization recovery step. Normal versions appear much sooner. Keep this wait read-only; a slow version is not permission to POST a duplicate freeze or version.

Save deployment_id. It stays stable when you later publish a newer frozen version of the same agent.

2. Connect each customer's required accounts

Derive external_user_id from your authenticated product session. Use a stable, opaque, non-secret ID from your system, at most 255 characters, that does not start with the reserved flymyai-owner- prefix. Do not accept an arbitrary ID from the browser, and do not use an email address as the tenant key.

For every connection_required slot returned by access, create a short-lived connection session from your backend:

connect-session has no caller idempotency key. Persist the canonical deployment, customer, slot, and audit label before one dispatch. If the response is lost, do not immediately create another session. Reconcile through access and the known 15-minute window, and record an unknown outcome if uniqueness cannot be proven.

customer_id = "customer_42"
slot = "gmail"

session_response = requests.post(
f"{BASE}/deployments/{deployment_id}/connect-session/",
headers=HEADERS,
json={"external_user_id": customer_id, "slot": slot},
timeout=30,
)
session_response.raise_for_status()
redirect_url = session_response.json()["redirect_url"]

Send only redirect_url to that authenticated customer. It expires after 15 minutes and is single-use. After they authorize the third-party service, query access with the same external_user_id until the required connection is active and unexpired.

3. Run the same deployment for that customer

run_key = os.environ["PERSISTED_DEPLOYMENT_RUN_KEY"]
# Load a key reserved with the canonical request in durable storage before this process.
run_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,
)
run_response.raise_for_status()
execution_id = run_response.json()["id"]

Reuse run_key only for the 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.

Passing external_user_id switches supported hosted connector resolution to that customer's principal. It does not choose a mapping or account by itself. The builder's API key and billing identity do not switch. Your product can meter the returned execution and apply its own quotas, credits, markup, or billing.

Choose one mapping mode for each run:

Mapping modeRun fields
FlyMyAI-managed saved bindingsOmit both resource_set_id and connections
Customer-managed named mappingSend resource_set_id and its saved resource_set_revision
One-off explicit mappingSend connections keyed by frozen slot

resource_set_id and connections are mutually exclusive. A named mapping is an external-principal resource set containing exact integration_connection public UUIDs. Store its opaque public ID and revision in your backend, not in browser state. The complete creation and concurrency flow is in Multiple MCP Accounts and Resource Sets. The backend still accepts an omitted revision for compatibility, but the public safe workflow always sends both saved values so stale state fails closed.

For example, the named-mapping body for the same deployment endpoint is:

run_body = {
"external_user_id": customer_id,
"variables": {"since": "2026-08-15"},
"resource_set_id": os.environ["CUSTOMER_MCP_RESOURCE_SET_ID"],
"resource_set_revision": int(
os.environ["CUSTOMER_MCP_RESOURCE_SET_REVISION"]
),
}

For a one-off mapping, each connections value is either one exact integration-connection public UUID or an array of UUIDs for a pooled slot:

run_body = {
"external_user_id": customer_id,
"variables": {"since": "2026-08-15"},
"connections": {
"support_mailbox": support_connection_id,
"archive_mailbox": [archive_connection_id],
},
}

Use only frozen slot names, satisfy cardinality, and include every required slot when this is meant to be a complete explicit mapping. Omitting a slot is not the same as clearing it: current runtime behavior continues to the saved binding for that slot. Every supplied ID must belong to this external principal. Owner connection IDs, foreign-customer IDs, unknown slots, and credentials in place of IDs are invalid.

Embedded customer runs currently accept variables and supported connection selection, but not per-run agent_file_external_ids. A file-input workflow must keep files in a supported frozen/runtime source or wait for embedded customer file attachments; sending that field returns HTTP 400.

Missing or invalid customer connections currently return an HTTP 400 error under connections. Create connection links through connect-session; do not treat every non-success response as a missing connection because authentication, validation, rate-limit, and server errors need different handling.

Preflight is the compatibility boundary

external_user_id does not make every arbitrary or owner-only custom MCP customer-compatible. The embedded preflight is authoritative. If it rejects an attached toolkit, do not silently split the logical job into multiple agents. Keep the worker whole, report the unsupported requirement, and either use a supported hosted adapter or wait until that toolkit has an embedded authority contract.

For connection state, re-authorization, multiple accounts per slot, and the complete polling loop, continue with Per-customer MCP access. For lower-level REST details, see Call Your Agent from Your Product.

Step 6 - release a change without cloning the agent

When behavior changes:

  1. Update the same AGENT_ID.
  2. Run and approve a new execution.
  3. Freeze that execution into a new reviewed compilation, then materialize a new immutable version.
  4. For a customer deployment, stage the new version as the candidate.
  5. Run preflight and publish it when ready.

The old version remains immutable, and the stable DEPLOYMENT_ID does not change. Rollback is a version decision, not a reason to create another logical agent.

Stage both upgrades and rollbacks on the existing deployment:

next_version_id = "<new or prior immutable version UUID>"

staged = requests.patch(
f"{BASE}/deployments/{deployment_id}/",
headers=HEADERS,
json={"candidate_version": next_version_id},
timeout=30,
)
staged.raise_for_status()

preflight = requests.post(
f"{BASE}/deployments/{deployment_id}/preflight/",
headers=HEADERS,
json={"publish_mode": "embedded"},
timeout=30,
)
preflight.raise_for_status()
if not preflight.json()["ready"]:
raise RuntimeError("Candidate is not ready")

published = requests.post(
f"{BASE}/deployments/{deployment_id}/publish/",
headers=HEADERS,
json={"publish_mode": "embedded"},
timeout=30,
)
published.raise_for_status()

Step 7 - run the clean-agent release gate

Run both gates early against the release candidate, before broad regression suites and before production promotion. They are deliberately independent:

  1. The deterministic API gate proves the backend lifecycle and A/B isolation.
  2. The fresh-assistant gate proves that a new assistant can discover and follow that lifecycle from only the exact candidate skill.md, without a split, freeze, deployment, or polling hint in its task prompt.

These are release-maintainer fixtures, not extra workers in the user's requested lifecycle. Run them in a separate labeled test scope. The fresh-assistant fixture creates exactly one disposable evaluation agent; it must not create a fixture agent and then create a second agent for the requested job.

From FlyMyAI/benchmarks_agent, first run the local graders, then the two opt-in live gates:

export FLYMYAI_RELEASE_GATE_SKILL_URL="$CANDIDATE_SKILL_URL"
export FLYMYAI_AGENT_BENCHMARK_MCP_URL="$CANDIDATE_MCP_URL"
export FLYMYAI_RELEASE_GATE_API_ROOT="$CANDIDATE_AGENTS_API_ROOT"

python -m pytest \
api_tests/test_clean_agent_embed_contract.py \
api_tests/test_agent_doc_following_contract.py \
-m unit -q

python -m pytest api_tests/test_clean_agent_embed_contract.py \
-m release_gate --run-live-release-gate -q -s

python -m pytest api_tests/test_agent_doc_following_contract.py \
-m agent_benchmark --run-agent-release-benchmark -q -s

CANDIDATE_SKILL_URL must serve the release-candidate bytes, not the existing production URL. CANDIDATE_MCP_URL must end at the candidate MCP endpoint, and CANDIDATE_AGENTS_API_ROOT must be the candidate root ending in /api/v1/agents. Record all three endpoints plus the guide bytes and SHA-256. After deployment, repeat the fresh-assistant gate with the production skill, MCP, and Agents API URLs as a separate smoke.

The harness may mint synthetic customer IDs only in its trusted test identity store. Normal product IDs must come from authenticated server sessions; neither a prompt nor a browser request may choose them. For each controlled test customer, require exactly one external principal and one runtime snapshot. The two snapshots must keep the same deployment and immutable version, and each snapshot's billing_user must equal the user_id returned by /agents/me/, while execution and principal differ. Replay each exact request with its persisted key and require the original execution ID. Read both prices using the same builder key. Query deployment access separately for A and B and reject any connection or binding UUID that crosses principals. A connectionless adapter keeps this topology gate non-interactive; every real provider still needs its own acceptance test.

The runner must retain the exact prompt, guide bytes and hash, structured MCP transcript, operation ledger, IDs, REST evidence, and cleanup result. Cleanup only the labeled test resources: revoke test connections, disable both principals, archive the deployment, then soft-archive the agent. Immutable versions, snapshots, prices, and other audit rows remain. A successful revoke can leave a revoked connection audit row and an empty binding row; verify that terminal state instead of requiring physical deletion. Any missing or shared principal, changed agent/version/deployment, owner mismatch, duplicate replay, task-status fake freeze, or incomplete cleanup blocks release.

Handoff checklist

A complete authoring assistant should return all of the following:

  • one agent_id;
  • the accepted execution_id and evidence that it completed;
  • one compilation_id whose projected status is compiled or completed;
  • the frozen instruction_md or a link to inspect it;
  • the input and output schemas, or an explicit statement that there are no variables;
  • a private compilation snippet, an embedded deployment snippet, or both;
  • when embedded, one stable deployment_id, the exact access requirements, and the preflight result;
  • when owner resources include several accounts, the exact connection and resource-set public IDs plus the accepted set revision;
  • when embedded, the explicit mapping mode and, for a customer-managed named mapping, its principal-scoped resource-set ID and revision;
  • a real test execution and its final output;
  • for an embedded release, two controlled external_user_id calls sourced by the trusted release harness, with distinct principal and execution IDs on the same agent, version, and deployment;
  • a clear statement that FlyMyAI bills the builder while the builder handles any onward customer billing.

If those pieces are present, the agent is not merely created - it is tested, frozen, and ready to ship.