make-python-recipe-deployable
Makes an existing Python recipe deployable: generates the serving files a container needs (Dockerfile, .dockerignore, fast_api_app.py, app_utils/a2a.py, app_utils/services.py, app_utils/reasoning_engine_adapter.py) and configures the recipe to match (required serving dependencies, the App object in agent.py, the hatch wheel package, manifest.deployable). Interactive by design — it asks the recipe owner about runtime data directories and stops for a human decision when a recipe needs an ADK migration or carries a legacy app_utils generation. When docker is available it offers to PROVE the claim: it builds the generated Dockerfile, runs it, probes it, and refuses to flag a recipe deployable if the container does not come up. Does NOT deploy or write terraform. Use when the user wants to "make this recipe deployable", "add a Dockerfile to a recipe", "add the serving files", "containerize a recipe", "verify the container builds", or prepare a recipe for Cloud Build / Artifact Registry.
Warnings for the author
- Not listed or searched: a skill under a hidden directory is discoverable only when the repository has no visible skill.
Make a Python Recipe Deployable
A deployable recipe is one that can be packaged into a container and run as a service. This skill writes the files that requires and configures the recipe to match.
It does not build an image, deploy anything, or provision infrastructure. Image builds happen later via Cloud Build → Artifact Registry; this skill's job ends when the files are correct.
The standard it implements lives in .github/policy.yml under deployability:
— the minimum google-adk version, the required dependency list, the required
file list, and the legacy app_utils file list. Change the standard there,
not in the script.
What "deployable" means here, and the one distinction that matters
deployable in .github/schemas/manifest-schema.json means "can be deployed
with one click". Two independent questions decide the outcome:
- Does it need infrastructure a human must provision? If yes it is containerized, not one-click deployable.
- Did we PROVE the container works, or only assume it?
| Outcome | Meaning | manifest.deployable |
|---|---|---|
deployable-verified | No bespoke infra, and the image built and served. | set to true, on evidence |
deployable-unverified | No bespoke infra, but nothing built it. | set to true, on static checks |
containerized-verified | Built and served, but needs backing infra. | left unset |
containerized-unverified | Needs backing infra, and unproven. | left unset |
verification-failed | Docker was usable and the recipe failed. | left unset, and a pre-existing flag is retracted |
verification-inconclusive | Verification was attempted and defeated by the environment — network, registry, or a runtime that could not exec the image here. It proves nothing, so nothing is retracted. Not the same as -unverified: that means nobody tried, this means you did and should retry. | set if static checks earned it |
blocked | The run stopped without a usable verdict. Four causes: a gate refused it up front (nothing written); a hard ERROR disqualified the recipe; the skill itself faulted; or verification was deferred pending uv lock — a pause, not a judgement, so re-invoke to finish. Check files_written (only a gate stop guarantees a clean tree) and read container-verify to tell a deferral from a real stop. | untouched — EXCEPT that a disqualified recipe has a stale flag retracted. Disqualified means a hard ERROR or no usable app object in agent.py, and the latter records no ERROR — so never infer from the absence of an ERROR that the tree was left alone. |
Never describe a containerized result as "deployable" to the user. Setting
that flag on a recipe that still needs hand-written terraform puts a false
claim in the manifest, which is worse than leaving it unset.
Equally, never describe an -unverified result as proven. It means the files
are right by inspection and nobody built the image — which is the normal
result on a machine without docker, not a defect.
Why -unverified still sets the flag. Absence of evidence is not evidence
of absence. Withholding it whenever docker is missing would judge the recipe
by the checker's laptop rather than by its own quality, and this skill's
primary user has no container runtime at all. Only a verification that
actually failed withholds the flag — a recipe proven broken is not
deployable, whatever the static checks said.
What generating a2a.py does and does not prove
Nothing, on its own. These templates were designed assuming the project was
scaffolded by agents-cli, and a file named a2a.py does not make an agent
behave correctly over A2A. The skill copies and configures; it does not
certify. Say so in your summary — do not tell the owner their recipe "supports
A2A" because the file exists.
Rules for the agent
- Confirm before applying. Always run a dry-run first, show the plan, and
get a "yes" before
--apply. The skill writes six files and edits three. - Ask the questions in the Interview below before applying — but only the ones the dry-run shows are relevant. Do not interrogate the owner about data directories for a recipe that has none.
- Never override a gate on your own.
adk-locked-version,adk-version-floorandlegacy-app-utilsreturnneeds_inputand stop the run. Each means a human has to change code. Report the message verbatim and stop; do not go hunting for a way around it. - Never widen an existing version bound to satisfy the standard. The
script leaves version specifiers exactly as the recipe wrote them and
reports them for confirmation. It does merge in missing extras
(
google-adk→google-adk[gcp,otel-gcp], same version bound), because the generated code imports what those extras install and the recipe would not start otherwise. Those are different risks: an extra only adds a package's own optional dependencies, while a version rewrite can move the recipe onto code it was never tested against. - Do not overwrite an existing
fast_api_app.pywithout explicit confirmation. An existing one is usually bespoke —long-horizon-harness's is ~400 lines of custom routing.--overwriteexists but is a deliberate choice, not a default. - Run the follow-ups yourself after a successful apply (see Step 5); the script does not, so a failure is attributable to the right step.
- Stay inside the recipe. If a run reveals problems in a different recipe, mention them and move on.
Pipeline
Step 0 — Dry run
uv run --no-project --with tomlkit --with 'ruamel.yaml' --with packaging \
python3 .agents/skills/make-python-recipe-deployable/scripts/make_deployable.py \
--recipe-dir <RECIPE_DIR>
Prints a JSON report: outcome, agent_package, checks, todos, notes.
Nothing is written. Exit code 0 = fine, 1 = a gate needs human input,
2 = error.
Summarise it for the owner. Do not dump the raw JSON.
Step 1 — Handle gates
If any check is needs_input, stop. The three that gate:
adk-locked-version—uv.lockresolvesgoogle-adkto an older major than the standard requires. The declared specifier may well permit the newer version, which is exactly the trap — in both directions. Re-locking IN PLACE keeps the old major (uv is sticky), so the recipe would ship the new serving dependencies against an ADK that cannot support them; resolving FRESH crosses the major silently, and the agent code has only ever run against the old one. The owner must port the agent first. This script rewrites metadata; it cannot migrate code.adk-version-floor— the specifier itself excludes the required version (a<2.0.0ceiling, an==1.31.0pin). Same conclusion.legacy-app-utils— the package carries the old ASP-era generation (telemetry.py,typing.py,deploy.py,memory_config.py). Filenames do not collide with the new set, but the two wire telemetry and services differently and the existingfast_api_app.pyimports the old ones. Generating over the top orphans them or double-wires telemetry. A human decides how to migrate.
Also check already-deployable. If it is report_only, the recipe already
serves and you must confirm the owner wants to migrate onto the standard
layout before applying — see the advisory at the bottom of this file.
Step 2 — Interview
Ask only what applies. Keep it to one round.
- Runtime data directories. Does the agent read anything at runtime that
is not in the agent package —
assets/,sample_data/, a config file? Those needCOPYlines or the container fails at request time, not at build time, which is why a human confirms rather than the skill guessing. Pass them as--data-dirs assets,sample_data. - An existing serving file was found. The dry-run reports each as
report_only. Ask whether to keep it (default) or replace it (--overwrite). Show what the existing file does first. - Version bounds that sit below the standard. The report lists any
existing requirement it left alone (e.g.
google-adk>=2.2.0against a>=2.6.0standard). Resolution usually lands on a satisfying version anyway —a2a-sdk>=1.0forcesgoogle-adk>=2.5on its own — but confirm the owner is happy rather than rewriting their pin. - Deployment region. Nothing in a recipe declares one, and it goes into
agents-cli-manifest.yaml, so it is a real decision. Defaultus-east1(agents-cli's own). Pass--region us-central1etc. - Backing infrastructure. If the outcome is
containerized, confirm the owner understandsmanifest.deployablestays unset and why.
Step 3 — Apply
uv run --no-project --with tomlkit --with 'ruamel.yaml' --with packaging \
python3 .agents/skills/make-python-recipe-deployable/scripts/make_deployable.py \
--recipe-dir <RECIPE_DIR> --apply [--data-dirs a,b] [--overwrite] \
[--region us-central1]
Step 4 — Report what changed
List files_written and the checks that moved to fixed.
Step 5 — Follow-ups (you run these)
When the script changes dependencies the lockfile goes stale, and the new files are unformatted. In order:
cd <RECIPE_DIR> && uv lock --python 3.11
--python 3.11 because CI pins it — locking with a newer local interpreter
produces a lockfile CI rejects with a misleading "out of date" error.
Run the lock command the report's todos actually give you, and if they give you none, skip it. The report picks between three states rather than always asking for a re-lock:
| Report todo | State | What to run |
|---|---|---|
uv lock --upgrade-package google-adk --python 3.11 | Pinned below the ADK floor | That command — a plain uv lock here is a no-op |
uv lock --python 3.11 | This run changed dependencies, or uv says the lockfile is out of date | A plain re-lock |
| no lock todo | Nothing changed and uv lock --check passes | Nothing — re-locking would only churn uv.lock |
The third row is why an idempotent re-run is quiet. The script asks
uv lock --check before staying silent, so an earlier run that added
dependencies and never locked still produces the todo.
If adk-locked-version came back report_only, the recipe is pinned below
the ADK floor — uv keeps any locked version that still satisfies the declared
specifier. The report will hand you this instead:
cd <RECIPE_DIR> && uv lock --upgrade-package google-adk --python 3.11
Then confirm the resolved pair. An ADK below 2.5 alongside a2a-sdk 1.x looks
fine in the lockfile and dies at import with cannot import name 'TextPart' from 'a2a.types' — invisible to every static check, and one of the reasons
Step 6.5 exists.
# from the REPO ROOT, so the root ruff config wins
uv run ruff format <RECIPE_DIR>/ && uv run ruff check --fix <RECIPE_DIR>/
Then the repo validators:
uv run validate manifest <RECIPE_DIR>
uv run validate structure <RECIPE_DIR>
cd <RECIPE_DIR> && uv run pytest tests/ -q
Step 6 — Boot check (the real proof)
Static checks cannot tell you the recipe actually serves. This can, it needs no
container runtime, and it is the closest thing to a correctness oracle the
skill has. Run it from inside the recipe after uv sync:
cd <RECIPE_DIR> && uv sync --python 3.11 && uv run --python 3.11 --with httpx python -c "
import warnings; warnings.filterwarnings('ignore')
from fastapi.testclient import TestClient
from <PKG>.fast_api_app import app
with TestClient(app) as c: # entering runs the lifespan
print('/list-apps ->', c.get('/list-apps').status_code, c.get('/list-apps').json())
card = [r.path for r in app.routes if 'well-known' in r.path]
print('agent card ->', c.get(card[0]).status_code if card else 'NO A2A ROUTES')
"
Expected: /list-apps returns 200 listing the agent package, and the agent
card returns 200. Entering the TestClient context is what triggers the
lifespan — without it the A2A routes never attach and the check is worthless.
If the agent card 404s or no A2A routes exist, the A2A wiring did not take effect. Report that plainly; do not describe the recipe as A2A-capable.
Warnings about experimental InMemoryCredentialService are expected and
harmless.
Step 6.5 — Container verification (ask first)
Step 6 proves the app boots on this machine. This proves the image the recipe will actually be deployed as. It is the only step that turns "we generated a Dockerfile" into evidence, and it is what lets the word "deployable" mean anything.
Look at the docker check in the report. It is present in every run,
including the dry-run, and its details.docker_state is one of:
| State | Meaning | What you do |
|---|---|---|
absent | No docker on PATH. | Skip. Say nothing alarming — this is the common case, not a problem. |
unreachable | Binary present, daemon not answering. | Skip, same as above. Mention the daemon is down in case they want to start it. |
usable | Daemon responding. | Ask the owner (below). |
When and only when the state is usable, ask:
Docker is available. Shall I build the generated Dockerfile and check the container actually serves? It takes a few minutes, and it means
manifest.deployableis set on evidence rather than on inspection. If the container does not come up, I will not set the flag.
If they decline, carry on — the outcome ends -unverified and that is a
legitimate result. Do not decide for them, and do not skip the question
because verification seems slow.
On a "yes", re-invoke with both --apply and --verify-container.
--verify-container does not imply --apply — on its own it reports that
verification needs the files on disk, and builds nothing:
uv run --no-project --with tomlkit --with 'ruamel.yaml' --with packaging \
python3 .agents/skills/make-python-recipe-deployable/scripts/make_deployable.py \
--recipe-dir <RECIPE_DIR> --apply --verify-container [--data-dirs a,b]
Run it after Step 5's uv lock, not before. The Dockerfile runs
uv sync --frozen, which cannot succeed until the lockfile matches. If the
lockfile is stale the script does not build — it defers manifest.deployable,
returns needs_input, and tells you to lock first. That is by design: a build
attempted against a stale lockfile fails with an error that looks exactly like
a broken template and is not.
What it does: builds for linux/amd64 (Cloud Run's platform), runs the
container with the recipe's own .env.example values plus the policy's
container_env, polls /list-apps until it answers, then probes the A2A
agent card. It removes the container and the image afterwards.
Reading the result:
container-buildERROR — the Dockerfile does not build. The check carries adetails.hintnaming the likely structural cause. Report it and stop; the recipe is not deployable.container-servesERROR — the image builds but the app does not come up. The log tail is in the message.manifest.deployablewas not set.container-a2aREPORT_ONLY — it serves, but the agent card did not return 200. The A2A wiring did not take effect. Say so plainly and do not call the recipe A2A-capable.
⚠️ Running a container is allowlisted, not automatic. Recipes not on
deployability.verification.run_allowlist are built only, because some
create real cloud resources at import — core/python/cross-session-memory
calls client.agent_engines.create() at module scope. A build-only result is
reported as unproven, never as a pass. Add a recipe to the allowlist only
after reading its package for import-time side effects.
Step 7 — Close out
Walk the report's todos with the owner — .env.example entries for any new
variables (the extract-python-environment-variables skill does this), and
terraform if the outcome was containerized.
If you created a .venv in the recipe to run Step 6 and it was not there
before, remove it.
Deliberately not in scope
| Not done | Why, and what does it instead |
|---|---|
| Deploying, or p |
Files of this skill
.agents/skills/make-python-recipe-deployable/resources/templates/Dockerfile.agents/skills/make-python-recipe-deployable/resources/templates/app_utils/a2a.py.agents/skills/make-python-recipe-deployable/resources/templates/app_utils/reasoning_engine_adapter.py.agents/skills/make-python-recipe-deployable/resources/templates/app_utils/services.py.agents/skills/make-python-recipe-deployable/resources/templates/fast_api_app.py.agents/skills/make-python-recipe-deployable/scripts/make_deployable.py.agents/skills/make-python-recipe-deployable/tests/conftest.py.agents/skills/make-python-recipe-deployable/tests/test_make_deployable.py