Min Aspire-publisher gik i en uendelig løkke på E_COOLIFY_ENVIRONMENT_UPSERT_FAILED. Opgaven var at deploye en HelloWorld-API til min egen Coolify-instans. Tokenet var gyldigt, serveren svarede, og projekterne blev oprettet korrekt. Men der manglede en destination — Coolifys betegnelse for det netværk, der hoster en container — og der fandtes intet API til at oprette en.
Funktionen findes i brugergrænsefladen. På en server klikker man »+ Add« under Destinations, og en destination er oprettet. Forsøger man derimod det samme via REST, eksisterer endpointet ikke. Det er en blind plet i API-fladen.
To muligheder lå åbne. Enten skulle min publisher fremover bede brugeren om at oprette destinations manuelt i UI'et før første deploy. Eller også kunne jeg åbne Coolifys kildekode og vurdere, hvor stort et indgreb det krævede at lægge endpointet på selv.
Klokken 11:18 formulerede jeg det sådan:
Tre timer senere lå PR #10405 hos coollabsio/coolify. Det interessante er ikke selve PR'en, men hvad der skete mellem spørgsmålet og indsendelsen.
En sub-agent læser kildekoden
Det første agenten gjorde, var ikke at åbne min editor og selv begynde at læse Coolifys kildekode. Den spawnede i stedet en tmux-orchestrator-sub-agent og gav den en research-prompt.
Det er et mønster, jeg gradvist har tilegnet mig: hovedsamtalen er for værdifuld til at bruge tokens på recon. Hvis hovedsessionen scroller gennem Coolifys 200.000+ linjer Laravel-kode, er konteksten brugt op, før selve patchen overhovedet bliver skrevet. Derfor blev recon-arbejdet skubbet videre til en frisk Claude-instans med ét formål. Her er den nøjagtige prompt, sub-agenten blev startet med:
You are a focused research child investigating Coolify v4's source code to scope an upstream PR that adds REST API endpoints for destinations (currently only available via the UI).
CONTEXT
- Coolify v4 (current beta.470) exposes
/api/v1/servers,/api/v1/projects,/api/v1/applications, etc. but has NO/api/v1/destinationsendpoints, even though the UI has a Destinations section ("Network endpoints to deploy your resources"). - The
destination_uuidfield IS required when creating an application via the existingcreate-public-applicationendpoint, so destinations exist as a backend concept — just no public API to list/create them. - We're consuming Coolify from a .NET Aspire publisher (pks-aspire-coolify) that needs to upsert destinations during deploy. Without an API, our publisher is blocked.
DELIVERABLES (write to docs/coolify-destinations-api-pr.md, ≤300 lines)
1. Where the Destination model already lives
Find the Eloquent / data layer. Cover:
- The
StandaloneDockermodel file path + key fields - Any
Destinationparent class or shared interface - How the UI's destinations page creates them today — find the Livewire / Volt component + the controller / action that does the actual create
- Whether multiple destination kinds exist beyond StandaloneDocker
2. Look at how an existing similar API was wired
Pick the closest existing API surface — probably Servers. Cover controller, route registration, request validation, response shape, list/create/show/delete pattern.
3. Sketch the new API surface
Propose exact endpoints. At minimum: list all, list by server, create on server, show one, delete one. For each: request schema, response shape, auth/middleware.
4. PR-shaped diff
Sketch exact files to add/modify:
app/Http/Controllers/Api/DestinationsController.php(new) — full ~80–100 line controller, inline in the docroutes/api.php— the lines to add- migrations only if needed
- tests under
tests/Feature/Api/
5. How to patch the running Coolify locally
The user's Coolify runs as the coolify docker container, source at /var/www/html. Walk through docker cp of the new controller, route edit, php artisan route:clear, curl verification, and rollback.
6. PR submission steps
Fork name, branch name, commit template, PR description template, related issues to cite.
7. Effort estimate
Lines changed, maintainer merge likelihood (look at their merge history for similar API additions), conflict risk vs open PRs. End with a one-sentence go/no-go.
Tools: WebSearch + WebFetch on github.com/coollabsio/coolify, raw.githubusercontent.com, coolify.io/docs/api-reference/. Use gh if available. NO code changes anywhere — pure recon + writing the doc.
When done, print DONE and exit.
Sub-agenten startede med et tomt kontekstvindue og havde 17.000 tegn til rådighed til at læse det nødvendige materiale. Tolv minutter senere var den færdig. Hovedsamtalen modtog aldrig de 17.000 tegn — kun opsummeringen.
Tabellen, der gjorde det til en beslutning
Det er én ting at få oplyst, at noget er »muligt«. Det er en anden at få det leveret som et konkret estimat. Det følgende er, verbatim, hvad sub-agenten returnerede:
| Estimate | |
|---|---|
| Lines added | ~120 (controller 95 + routes 6 + tests 30–50) |
| Local-dev time | 1–2 h to write + smoke-test in the running container |
| Maintainer merge odds | Moderate-to-good for small additive API PRs |
| Time-to-merge guess | 1–3 weeks if maintainers engage; faster with OpenAPI @OA\ annotations |
| Conflict risk | Low; one existing related issue (#8645) about destination-API gap |
Det er recon delivered i sin reneste form. Da tabellen lå på skærmen, vidste jeg endnu ikke, om svaret ville blive ja — men jeg vidste præcis, hvad jeg sagde ja eller nej til. Forskellen er afgørende: ikke »lad os prøve«, men »lad os afsætte 1-2 timer, der er omkring 120 linjer i spil, og den primære risiko er Pest-tests og OpenAPI-formalia.«
Det er præcis den type estimat, min senior-ingeniør-hjerne ellers selv skulle have produceret. Den ville have været mere upræcis, taget 30 minutter at lave i stedet for 12, og forbrugt kontekst-budgettet undervejs.
»Recon delivered« er ved at blive et fast led i mit workflow.
Fork, branch, controller, gist
Resten gik hurtigt. Fork af coollabsio/coolify til pksorensen/coolify. Tilføjet som submodule under external/coolify. Branch feat/api-destinations. DestinationsController.php skrevet ud fra recon-dokumentets diff. Patch på routes/api.php. Commit. Push.
Men her opstod en udfordring: patchen skulle testes mod min egen kørende Coolify, før PR'en blev åbnet. Coolify kører som en Laravel-app i en Docker-container på min Hetzner-maskine. Jeg ønskede hverken at pakke en custom Coolify-build eller at rebuilde imaget.
Løsningen blev en bash-installer i en GitHub gist. Den henter de patchede filer fra pksorensen/coolify@feat/api-destinations, kører docker cp ind i containeren, eksekverer php artisan route:clear og verificerer, at de fem nye endpoints fremgår af route:list.
curl -fsSL https://gist.githubusercontent.com/pksorensen/98202004189b63e210cddd937a050a0e/raw/install-coolify-destinations-api.sh | bash
Én curl-kommando, ét bash-pipe, og min produktions-Coolify havde det patchede API.
Det første forsøg fejlede naturligvis. Call to undefined function auditLog() — sub-agenten havde antaget, at en hjælpefunktion eksisterede globalt, men den tilhører en specifik trait. Rettet. Andet forsøg: Attempt to read property "id" on null — Auth::user() returnerede null, fordi api.sensitive-middlewaren strippede sanctum-konteksten undervejs i request-pipelinen. Rettet. Tredje forsøg:
HTTP 200
GET|HEAD api/v1/destinations ............. Api\DestinationsController@index
GET|HEAD api/v1/destinations/{uuid} ....... Api\DestinationsController@show
DELETE api/v1/destinations/{uuid} ..... Api\DestinationsController@delete
GET|HEAD api/v1/servers/{server_uuid}/destinations Api\DestinationsControl…
POST api/v1/servers/{server_uuid}/destinations Api\DestinationsControl…
Alle fem endpoints registreret. aspire deploy kørte til ende. HelloWorld-API'en lå live i min Coolify få minutter senere — på en destination, som publisheren havde oprettet via det API, der formelt set ikke fandtes.
På dette tidspunkt havde jeg den interessante version af løsningen: en patchet Coolify i produktion (om end min produktion) og en branch på min fork, klar til at blive åbnet som PR opstrøms.
STRAWBERRY
Så åbnede jeg PR-templaten i coollabsio/coolify. Det er en velstruktureret template med sektioner for Changes, Issues, Category, Testing, Preview og en sektion kaldet »AI Assistance«. Jeg indsatte draft-beskrivelsen. Agenten kiggede med.
Den stoppede mig.
Det er den mest elegante defensive prompt-injection, jeg har set i praksis.
Det er ikke et udtryk for modvilje mod AI. Det er ikke en politik om »ingen AI-PRs«. Det er en tripwire, der specifikt fanger de PRs, hvor en AI har genereret beskrivelsen, uden at et menneske har læst den igennem. Et menneske ser HTML-kommentaren, undrer sig over instruktionen og udelader ordet. En AI, der kører prompten gennem en LLM uden review, vil pligtskyldigt tilføje STRAWBERRY øverst — og blive auto-lukket.
Det er en lakmusprøve på human-in-the-loop. Og den er kalibreret så præcist, at den ikke straffer AI-assisterede PRs (jeg markerede selv »AI was used« i templaten og beskrev specifikt, hvad Claude blev anvendt til). Den straffer udelukkende unsuperviserede AI-PRs.
Den ironiske pointe: grunden til, at jeg fangede mekanismen, var, at min agent fangede den. Det er præcis dén adfærd, maintainerne ønsker — at et menneske eller en superviseret agent gennemlæser templaten og spotter fælder som denne, frem for at et wrapper-script blindt pumper indholdet gennem en chat-completion. Min agent var i loopet, så tripwiren arbejdede for mig i stedet for imod mig.
Tidslinje
| UTC | Hændelse |
|---|---|
| 11:18 | »can we check the coolify source code...« |
| 11:19 | Agenten spawnede recon-sub-agent i baggrunden |
| 11:31 | Recon delivered: coolify-destinations-api-pr.md + go/no-go-tabel |
| 11:35 | Fork oprettet, submodule tilføjet |
| 11:45 | Controller og routes skrevet, branch pushed |
| 11:55 | Gist-installer publiceret |
| 12:00 | Første installation mod live Coolify — auditLog()-fejl |
| 12:10 | Anden iteration — Auth::user() null-fejl |
| 12:18 | Tredje iteration — alle fem endpoints i route:list, HTTP 200 |
| 12:20 | aspire deploy gennemført mod patchet Coolify |
| 12:35 | PR-draft skrevet til /tmp/pr-description.md |
| 12:38 | STRAWBERRY-tripwiren identificeret |
| 12:45 | PR #10405 åbnet |
Wall-clock fra det indledende spørgsmål til en åben opstrøms-PR: omkring tre timer. Inden for samme vindue kørte den patchede version i min egen produktion.
Den egentlige pointe
Dette er anden indlæg i »byg det selv«-serien, men det første, hvor det, jeg byggede, ikke var et nyt stykke standardsoftware. Det var en udvidelse af en andens. Princippet er det samme i en anden form: når man støder på en mangel, har man nu to retninger at gå. Enten bygger man uden om den. Eller man patcher kilden.
Sidstnævnte plejede at være en betydelig beslutning — fork, vedligehold, divergens, opretholdelse af mergeability. Den vægt er reduceret markant. Når en agent kan rekognoscere kildekoden på 12 minutter, skrive en patch på 30 minutter og levere en gist, der gør det muligt at teste mod produktion på 2 minutter, så er »lad mig lave en PR« blevet en mulighed, der ligger på linje med »lad mig finde en workaround«.
Nu venter vi på maintainerne. PR'en kan blive merged. Den kan blive diskuteret. Den kan blive lukket på grund af manglende OpenAPI-annotationer. Det er uden afgørende betydning. Min publisher virker allerede mod min patchede Coolify; en opstrøms-merge er bonus.
Det interessante er, at jeg overhovedet stillede spørgsmålet. For et år siden ville jeg have skrevet en workaround. Nu skrev jeg en PR.
Denne post er blevet revideret 2 gange — se hele historikken
Det første udkast. Indeholdt 'barn' calque-mønster og essayistiske vendinger som blev rettet i v1.
Calques fjernet ('barn' → 'sub-agent'), essayistiske vendinger strammet. Stemmen bevaret. Senere omarbejdet til professionel stil i v2 (nu canonical).
- v2 — Professionel stilaktuel
Tredje iteration: omskrevet til en mere formel dansk teknisk stil. Talesprog erstattet med præcis tekniske formuleringer; kerneindhold, anekdoter og kode-detaljer bevaret. Den aktuelle publicerede version.
