En ALP-samlebåndslinje der hver morgen henter posteringerne ud af netbanken og skriver dem ind i et versioneret posteringsregnskab i projektets eget repo.
Det ene sted et menneske skal ind, er MitID — og det bliver stående dér. Opskriften trykker aldrig godkend. I stedet parkerer kørslen, linjen sender en notifikation med engangskoden, og et menneske godkender i appen. Så kører den videre af sig selv.
Det er hele formen: en linje der kan alt undtagen det ene, den ikke må kunne.
https://agentics.dk/import?repo=https://github.com/pksorensen/alp-reconciliation
Vælg projektet, importér. Linjen har tre stationer, og der skal ikke sættes noget op i
dem: Hent posteringer er den der arbejder — opgaven lander der, og kun der kører der
et job. Afstemt og Fejlet er endestationer uden trigger; opgaven flyttes selv
derhen af linjens to overgange, alt efter om jobbet melder success eller failure.
Melder jobbet hverken det ene eller det andet — timeout, afbrudt session — flytter
platformen ikke opgaven. Den bliver stående på Hent posteringer med sin konklusion
på kortet, og det er det rigtige signal: agenten nåede aldrig at fælde en dom, så
posteringerne kan ikke antages hentet.
Importen læser repoet uden token, så linje-repoet skal være offentligt. Det er derfor alt kundespecifikt — aftalenavn, kontoform, hvilken bank — ligger i projektets repo og ikke her.
Importen tilbyder to ting, begge tændt fra start: en startopgave, så den første afstemning kan køres med det samme, og en tidsplan — hver dag kl. 08:00 dansk tid.
Projektet skal have et git-repo tilknyttet (gitUrl). Det er dét repo jobbet kloner,
og det er dér posteringerne bliver committet. Det repo skal være privat. Linjen
skriver kontoudtog ind i det.
Et tomt repo er nok til at komme i gang. Regnskabet er kørslens resultat —
parse-exports.mjs opretter postings/, accounts.json, counterparties.json,
index.json, summaries/ og raw/ fra ingenting — så den første afstemning i et nyt projekt skal
ikke vente på, at nogen har lagt en mappestruktur.
Én værdi kan kørslen ikke skaffe ved at gætte: aftalenavnet. Eksporten skifter selv til den aftale i netbanken og asserter bagefter på, at den står der — det er assertionen, der forhindrer, at en fremmed virksomheds posteringer havner i regnskabet. Kontiene står derimod ingen steder: eksporten læser selv kontolisten under aftalen og henter dem alle, så en ny konto er med fra dag ét. Aftalenavnet står ikke i denne opskrift, for den ligger i et offentligt repo, og det er kundens.
Det skal heller ikke tastes af et menneske, der husker forkert. Det står i banken. Så den allerførste kørsel i et nyt projekt logger på én gang og spørger banken:
node tools/run-recipe.mjs --phase start --goal discover --out ./ud # parkerer ved MitID
node tools/run-recipe.mjs --phase resume --keep --out ./ud # skriver ud/discovery.json
--goal discover henter ingenting og ændrer ingenting. Den åbner bankens aftalevælger,
læser hvilke aftaler brugeren kan se og hvilke konti der ligger under den, der er valgt
nu, og lukker den igen. Resultatet er fire lister: aktuelAftale, aftaletyper,
aftaler og konti.
Først derefter spørger stationen mennesket — og nu er spørgsmålet et valg mellem
strenge, banken selv har skrevet. Det er ikke en detalje: svaret på et AskUserQuestion
føres ind ved at navigere i en radioliste, så fri tekst, der ikke matcher en mulighed,
vælger tavst den første. Et aftalenavn kan aldrig komme ind ad den vej. En liste kan.
--keep lader sessionen stå åben, så eksporten kan køre på den samme pålogning:
node tools/run-recipe.mjs --phase again --goal export --config <ledger>/config.json --out ./ud
Uden den ville en førstegangsopsætning koste to MitID-tryk — ét til at finde ud af, hvad der skal hentes, og ét til at hente det. En kørsel er et verbum på sessionen hos browser-servicen, og pålogningen hører til sessionen; det er hele mekanikken.
Svarer ingen — fordi klokken er 08:00, og ingen ser med — stopper første kørsel med at
skrive listerne i sin konklusion. Så kan config.json lægges i repoet i hånden, og i
morgen kører linjen den korte vej.
En regnskabsmappe — navnet er lige meget, stationen finder den — med en config.json:
{
"bank": "spard",
"agreement": "Firmanavn ApS",
"period": "Seneste 12 måneder",
"formats": ["CSV"],
"persistProfile": "spard-firmanavn",
"ledger": "."
}| Felt | Hvad det gør |
|---|---|
bank |
Hvilken opskrift der køres (recipes/<bank>.json). Feltet stationen genkender mappen på. |
agreement |
Aftalen i netbanken, som banken skriver navnet i «Vælg aftaler». Kørslen skifter selv til den og stopper, hvis den ikke står i topbaren bagefter — et kontoudtog fra det forkerte selskab er værre end intet kontoudtog. Alle konti under aftalen hentes; der er ingen kontoliste at vedligeholde. |
period |
Bankens egen forudindstilling. Standarden i netbanken er "I dag", og uden det her eksporterer man én dag og tror det gik godt. |
formats |
["CSV"] er nok til posteringerne. "PDF" koster 25-46 sekunder pr. konto og er kun et bilag. |
persistProfile |
Navnet på den huskede browserprofil. Uden den møder banken en ny enhed hver morgen — og en ny enhed betyder ekstra verifikation, hver morgen. |
Resten af mappen laver kørslen selv:
<ledger>/postings/<konto>/<ÅÅÅÅ-MM>.jsonl én postering pr. linje
<ledger>/accounts.json konti, saldo, sidste postering
<ledger>/counterparties.json modparter og hvad de fylder
<ledger>/index.json hvad hver kørsel hentede
<ledger>/raw/<ÅÅÅÅ-MM-DD>/… eksporten som den blev hentet
<ledger>/summaries/<ÅÅÅÅ-MM-DD>.md dagens nye linjer pr. konto — det, morgenens besked består af
Fordi en postering, der er committet, kan sammenlignes med gårsdagens med git diff.
Ændrer en linje sig, er det synligt. En database ville skjule præcis dét, og en
afstemning, hvor historikken kan ændre sig usynligt, afstemmer ingenting.
Det er en append-only filbutik med indhold-adresserede id'er — ikke en grafdatabase. Modpartsregisteret er en projektion, der bygges forfra ved hver kørsel, så det aldrig kan komme til at sige noget andet end posteringerne selv.
Kørslen er idempotent: hver postering får et id fra sha256 over konto, dato, tekst, beløb og saldo, og et id, der allerede står i filen, skrives ikke igen. Det er dét, der gør, at eksporten godt må dække tolv måneder hver eneste dag — overlappet forsvinder af sig selv, og en dag, der blev sprunget over, bliver samlet op i morgen i stedet for at være tabt.
Det står aldrig i en opskrift. Opskriften ligger i et offentligt repo; bruger-id'et
er personhenførbart. Værktøjet læser det fra MITID_USER_ID i miljøet.
I en station kommer det fra vaulten (ADR 0011 — stationen er vaultens agentidentitet):
vault agent run --vault "$VAULT_ID" --item "$VAULT_ITEM_ID" --env MITID_USER_ID=userId -- \
node /tmp/rec/tools/run-recipe.mjs --phase start --config <ledger>/config.json
Det kræver en ceremoni, der kun udføres én gang, og som er kryptografisk tvunget i den
rækkefølge: vault agent add → kør stationen én gang i hånden, den enrollerer og
blokerer → vault agent approve --fingerprint … → vault agent grant. Der er ikke
noget at godkende, før nøglen findes.
Uden vault-opsætning skal MITID_USER_ID bare stå i miljøet. Det er den rigtige form til
en hånd-kørsel og den forkerte til en daglig, uovervåget station.
Stationen erklærer sit behov i station.json — operatorConfig.vaultRequest — med
navnet på det den skal bruge, aldrig hvilken vault det ligger i. Repoet her er
offentligt og kan forkes; en konkret vault-id i det ville pege enhver forks station på en
fremmed vault. Importen på platformen slår navnet op i projektets egen vault, opretter en
vault hvis projektet ingen har, og skriver den konkrete peger på stationen.
To ting kan importen ikke gøre, og det er ikke en mangel der kan bygges væk:
-
Oprette selve emnet. Et vault-emne bliver forseglet med en nøgle, som en webserver ikke har. En POST uden ceremonien ville give et emne, ingen nogensinde kan åbne. Så importen advarer i stedet med kommandoen — nøgleholderen kører den selv:
vault item create <vaultId> 'mitid' --field userId --owner <navnerum> vault invite create <vaultId> 'mitid' --field userId="MitID bruger-id" \ --owner <navnerum> --link-base https://agentics.dkDen anden udskriver et link, bruger-id'et kan tastes ind i fra en telefon. Værdien krypteres i browseren; den del af linket der står efter
#når aldrig serveren.--link-baseer ikke pynt: uden den peger linket på vault-serveren, som med vilje ikke serverer andet end JSON. -
Give stationen adgang.
vault agent approvekræver et fingeraftryk, der først findes efter stationen har indrulleret sig. Rækkefølgen er tvunget af kryptografien, ikke af en UX-beslutning, og der findes derfor ingen forhåndsgodkendelse.
Og én ting mere, som kun viser sig i produktion: vault-serveren kører med OIDC, så
stationen skal ud over sin identitet også have en service-konto
(vault agent credentials --client-id … --client-secret …). Uden den indrullerer den
pænt, vault agent whoami ser rask ud, og hver eneste læsning svarer 401.
Værktøjet er afhængighedsfrit og kræver kun Node 20+.
git clone https://github.com/pksorensen/alp-reconciliation
cd alp-reconciliation
BROWSER_URL=https://browser.agentics.dk BROWSER_TOKEN=… MITID_USER_ID=… \
node tools/run-recipe.mjs --config ~/regnskab/config.json --out ./ud
node tools/parse-exports.mjs --in ./ud --ledger ~/regnskab--goal export er standard, og --phase run gør begge faser i én proces. Som job køres de hver for sig,
med notifikationen imellem — det er hele grunden til opdelingen: en agent, der sad og
pollede, kunne ikke sende noget imens, og en notifikation efter godkendelsen ville være
ubrugelig.
Mod en lokal browser-service: --server http://127.0.0.1:8099.
Kører linjen som et job, veksler run-recipe.mjs selv runnerens AGENTICS_TOKEN til et
5-minutters token udstedt til netop denne browser-service — samme model som npm's trusted
publishers. Der ligger ingen langlivet hemmelighed i containeren.
Det kræver, at ejeren én gang har bundet linjen til de to scopes hos browser-servicen:
PUT /v1/owners/<owner>/trust (med driftstokenet, ikke et fødereret)
{ "bindings": [{ "projectOwner": "<owner>", "project": "<projekt>",
"assemblyLineIds": ["<linjens id>"],
"scopes": ["browser:sessions", "browser:artifacts:read"] }] }
projectOwner skal være den samme ejer som i stien — en ejer kan ikke skrive en
binding, der giver en andens linje adgang. Udelades stationIds, gælder bindingen
alle stationer på linjen. Ruten tager kun imod driftstokenet: kunne en linje skrive
sin egen binding, var hele modellen pynt.
Linjens id findes først efter importen. Bemærk: en ny import laver en ny linje med et nyt id — den opdaterer ikke den gamle. Bind derfor først, når linjen står som den skal, og tag opdateringer ved at rette i den eksisterende linje, ikke ved at importere igen.
Uden føderation falder værktøjet tilbage på BROWSER_TOKEN. Det er reserven til
hånd-kørsler, ikke til stationen.
- Den bogfører ikke. Den henter og skriver ned. Afstemning mod et bogholderi — Dinero eller andet — er ikke bygget. Navnet er en retning, ikke en påstand.
- Den godkender ikke MitID. Ikke fordi det er svært, men fordi det ikke må: en opskrift, der trykkede godkend selv, ville være en opskrift på at give en tjeneste kundens identitet.
- Den kan kun én bank.
recipes/spard-*.jsoner Sparekassen Danmark. En anden bank er en ny opskrift ved siden af og etbank-felt, der peger på den.
Tre filer, ikke én. Login er sin egen, fordi det er den halvdel, begge mål deler — og fordi motoren ingen betingelser har: der findes ikke én opskrift, der både kan logge på og lade være. Værktøjet lægger dem i forlængelse af hinanden.
| Fil | Hvad den gør |
|---|---|
recipes/spard-login.json |
MitID-pålogningen. Slutter, når netbanken står åben. |
recipes/spard-discover.json |
Kigger: aftaler og konti. Henter og ændrer intet. |
recipes/spard-export.json |
Den daglige eksport: skifter til aftalen i config.json, finder kontiene under den og henter dem alle. |
De er lister af trin, ikke programmer: ingen udtryk, ingen betingelser, ingen model i
afspilningen. En håndfuld ting i dem ser ud som pynt og er det ikke — de står som
comment i filerne selv, hver især fundet ved at flytte en optagelse fra en rigtig
Chrome over i tjenestens Playwright, eller ved at måle på den åbne session bagefter. De
vigtigste:
- Cookie-muren sætter
role="alert"på knappen, sågetByRole('button')finder intet.#declineButtonvirker. Trinnet eroptional, fordi anden kørsel på en husket profil ikke møder muren. - MitID's bruger-id er ti
visibility: hiddeninputs. Playwright nægterfillpå noget usynligt og har ret. Derfor verbettype. - MitID sender ingen push af sig selv. Efter FORTSÆT viser den et valg. Uden klikket på "åbn app på anden enhed" sker der ingenting, og siden ser ud som om den venter.
- Engangskoden kommer først, når appen har åbnet forespørgslen — derfor
watchpå selve ventetrinnet i stedet for et opslag før. Og derfor kan stationens notifikation ikke bære koden: den er sendt, før koden findes.--phase resumesender den selv, som sin egen notifikation (MitID-kode 1 2 3 4), i det sekund den står på skærmen — mod platformens notifikationsrute, med runnerens token og job-id, ligesom føderationen. UdenAGENTICS_*i miljøet står den kun i terminalen somNOTIFY-KODE. - Periodevælgeren skal åbnes først.
role=menuitemcheckboxmatcher intet, før trækkeren er klikket. Og standarden er "I dag": uden de to trin eksporterer man én dag og tror, det gik godt. - Aftalevælgerens modal har ingen lukkeknap, og
Escapeer en fælde. Escape lukker indholdet, menReactModal__Overlaybliver stående og opsnapper hvert eneste klik derefter — netbanken er reelt frosset for automatik, og fejlen kommer først flere trin senere som en timeout på en knap, der tydeligvis er der. En navigation river React-træet ned og er den eneste rene vej ud. Derforgotoog ikkepress. - De to aftaletyper er ikke faneblade. Et klik på "Erhverv" eller "Privat" skifter aftale med det samme, lukker modalen og ændrer brugerens egen netbank. Opdagelsen klikker dem derfor aldrig — den læser dem. Eksporten klikker først på rækken med aftalenavnet og bruger vippen kun som faldbagud, hvis modalen stadig står åben; i den omvendte rækkefølge ville vippen kunne lande på en anden erhvervsaftale, og rækkeklikket ville aldrig nå at rette det.
- Kontooversigten har to visninger, og kun listevisningen har links. I kortvisning
findes
a[href*=accountId=]slet ikke i DOM'en, og en kørsel henter nul konti uden at fejle. Derfor klikkes listevisningen til først, og derfor asserter eksporten på mindst ét kontolink, før den løber listen igennem. - Konti løbes igennem fra bankens egen liste, på
href, og filen navngives efter linkets tekst. Så er der ingen kontoliste at holde ved lige, og kontonavnet er alligevel det, mennesket kan genkende.
waiting.data på et human-trin er den eneste kanal, og motoren sletter feltet igen i
sit finally. Listerne kan altså kun læses, mens trinnet står parkeret. Derfor slutter
opdagelsen med et human-trin, hvis until aldrig matcher, med et kort timeout og
optional: true: klienten poller kørslen, fanger listerne i vinduet, og trinnet bliver
sprunget over, så kørslen ender done og ikke failed.
Skelnen mellem hvad der gemmes, er formen: collect giver lister, watch giver strenge.
Derfor havner MitID-engangskoden aldrig i discovery.json, mens aftale- og kontolisterne
gør.
Det koster også seks sekunder om dagen i eksporten, som til sidst lægger aftalen og kontolisten ud samme vej. Det er kvitteringen på, hvilket selskab og hvilke konti dagens filer kom fra.
Motoren er dør B i pks-agent-browser. Dens egne noter står i
projects/pks-agent-browser/docs/recipes.md.