A Good Refusal Beats a Buildpack
Pilots detects compose files, Dockerfiles and known frameworks, and when it recognises nothing it returns a 400 carrying everything an agent needs to write a Dockerfile itself. Why there is no buildpack behind that.
Point a deploy at a directory and there are four possible outcomes. It has a compose file. It has a Dockerfile. It looks like a framework we recognise. Or it is something nobody has seen before.
The first three are ordinary engineering. The fourth is where platforms usually make a choice I disagree with, which is to install a buildpack system so that the answer is never "I do not know".
The resolution order, first hit wins
Per directory:
1. A compose file at the root. The compose planner compiles it, with the directory's own .env as the interpolation map. 2. A Dockerfile at the root. One service, built as written, health left to the rollout's defaults. A file the repo wrote beats a file the platform would write, always. 3. A recipe. One service per detected app, with a generated Dockerfile carried on the step. A root that declares npm workspaces is not itself an app: each workspace becomes a service named after its directory, built from the root with the appropriate working directory. 4. unknown_framework, which is a 400.
Rule 2 is the one I would defend to anybody who has been burned by a platform that decided it knew better. If there is a Dockerfile, we build the Dockerfile. It is not a hint, a fallback, or an input to a heuristic.
And detection runs on the host, behind POST /v1/plan, rather than in the client. A tar goes in, a plan plus one entry per detected step comes out. The CLI, the MCP server, the dashboard and the GitHub push path all call the same endpoint, so there is one copy of the rule. There used to be a second copy in the CLI, which was invisible to three of those four callers, and "the CLI detects it but a push does not" is a bug report that takes a while to even understand.
What the refusal carries
An unknown_framework response is a 400 whose details hold the directory listing, the manifests found, the workspaces, and the two Dockerfile rules the platform enforces.
That is the whole design. The refusal contains everything an agent needs to write a Dockerfile without reading the repository again.
Think about what the alternative costs. A plain 400 saying "could not detect framework" sends the agent back to enumerate the tree, guess at manifests, and try again, which is three or four more round trips through a model that is also guessing at what this platform expects. A refusal that hands over the evidence turns one failed call into the input for the next successful one.
The end-to-end battery drives exactly that loop. It points a deploy at a directory nothing recognises, reads the refusal, writes a Dockerfile from it, and deploys. Then, separately, it takes a webjs app with no Dockerfile and no compose file to a 200 through the router in one call. Those two cases are the product's front door tested as a test rather than claimed as a feature.
Why there is no buildpack
Railpack and its relatives were evaluated and rejected, and the reasoning is a cost list rather than a dismissal.
A buildpack system means a frontend image to maintain, a binary on every host, a second base image, and a second start-spec path alongside the one the Dockerfile parser already produces. Four durable surfaces, each needing to stay correct as the fleet changes.
What they buy is coverage of a long tail of languages and frameworks. And the long tail is exactly what a structured refusal covers, because an agent with the listing, the manifests and the rules writes a Dockerfile for a Crystal app faster than anyone maintains a buildpack for one.
So the long tail belongs to the agent, and what the platform ships is a refusal that is genuinely useful to it. That is one surface instead of four, and the coverage gets better every time models get better, without anyone shipping anything.
The port rule, which exists because of a 502
Every recipe declares PORT=8080, EXPOSE 8080, and uses ${PORT:-8080} in its start command.
The platform's port, never the framework's. The router dials 8080. An image that listens on 3000 builds perfectly, starts perfectly, passes nothing, and answers 502, and the reason is nowhere in the build log because the build was fine.
That is a small detail that turns into a support conversation every time it is not enforced, so it is enforced in the recipes themselves rather than documented as a convention. A reader with their own Dockerfile does have to listen on $PORT, which is the one thing rule 2's "we build what you wrote" does not do for you, and it is what the refusal's rules section says in so many words.
A failing build keeps every line
One last piece, because it belongs to the same loop. A build that fails still emits its complete NDJSON stream. The agent reads the failing step, patches the Dockerfile, and retries.
That is the reason the log is structured at all. Not for a dashboard, not for a pretty stream in a terminal, but so that the thing most likely to be reading it can act on it without parsing terminal colour codes out of somebody's compiler output.
Every note, or install the CLI and try the thing this one is about.