de/netzbetreiber-replies
Fetch and triage replies to the §14a outreach — classify each message, extract module data (from text, PDFs, or linked pages), record findings, draft follow-ups, then flush them through the guarded deterministic sender at the end of the pass.
Netzbetreiber reply triage & extraction
Campaign dir: campaigns/netzbetreiber/. You never compose-and-send email
directly — drafts go to followups-proposed.jsonl, and the ONLY sending happens
via the deterministic followups.mjs send in step 7, which applies send-time
freshness guards.
1. Fetch
Run node campaigns/netzbetreiber/fetch_mail.mjs (appends new mail to
inbox.jsonl, saves attachments under replies/).
Then run node campaigns/netzbetreiber/fetch_browser.mjs — it renders the links
a plain GET could not turn into a document (the large WAF-fronted operators) and
saves what it gets under replies/. It self-skips when no browser is configured
or nothing is queued, so it is safe to run every pass.
2. Select work
Also run node campaigns/netzbetreiber/pending_docs.mjs — it lists documents
already sitting in replies/ whose operator is still missing §14a fields, into
/tmp/pending-docs.json. These are files a previous pass fetched: the browser
path retrieves a document in one run and the message that named it was triaged
in an earlier one, so nothing else would ever point at them. Read each and append
a findings.jsonl record exactly as in step 4 (no new triage row — the message
was already triaged). Usually this list is empty; when it is not, it is the
cheapest data in the run.
Run node campaigns/netzbetreiber/pending_mail.mjs — it writes the worklist to
/tmp/pending-mail.json (oldest first, capped at 25, text pre-truncated) and
prints how many remain for the next run (include that in your summary). Work
from that file ONLY — do not read inbox.jsonl yourself (it is several MB and
reading it blows the time budget). Process every message in the worklist.
Append to triage.jsonl/findings.jsonl after EACH message, never in one
batch at the end — an interrupted run must keep its completed work.
Do ALL work synchronously inside this run. NEVER spawn background agents and end your turn "waiting" — this run terminates the moment you produce your final message, killing anything still running (2026-08-04: a run ended "waiting for the background triage agent" and zero messages got processed). Synchronous subagents are fine; background ones are not.
3. Classify each message
One of: answer_complete, answer_partial, answer_pdf (data in
attachment/link), auto_ack (ticket/receipt confirmation), out_of_office,
reroute ("write to X instead"), wrong_entity ("we are not the
Netzbetreiber"), refusal, question_back (human asks who/why — never argue,
route to human), do_not_contact (asks to stop contact), bounce
(mailer-daemon), irrelevant (spam/tests).
Rules:
- Bounces: the original message is usually attached — Read it to find the
NB-SNB…reference or+SNB…recipient, set the row's mastr_nr. - Unmatched messages (
mastr_nr: null): try to identify the operator from content/signature againstseed.csv; if still unclear →irrelevantor note for a human. - Trust content over subject (an "Automatische Antwort" that also contains prices is an answer).
4. Extract §14a data (for answer_* classes)
Target fields: modul_1_price (EUR/year), modul_2_price (ct/kWh),
off_peak_hours (time windows), modul_3_description (free text).
Sources, in order of effort: the email text; PDF attachments (use Read on the
saved file under replies/); linked Preisblatt URLs — for those run
node campaigns/netzbetreiber/fetch_reply_doc.mjs --mastr SNB…
which picks the URLs out of that operator's own messages, ranks them, follows a
landing page one hop deeper, and prints the saved path under replies/ to Read.
Never guess figures from a URL name. Do not hand-curl a link instead: the
script is what checks the URL actually came from that operator's reply and sits
on their own domain, and it un-wraps the links mail clients break across lines
(Bayernwerk's PDF link was truncated at …/bayernwerk-netz/ for weeks).
It exits non-zero when it could not get a document, having written the URL to
fetch-queue.jsonl for the browser path. That is not a failure to work around —
classify the message answer_pdf, action human_queue, and say in detail
that the document is queued. Do not try to fetch it another way.
Append one record per (message, operator) to findings.jsonl:
{"mastr_nr": "SNB…", "message_id": "…", "modul_1_price": "…" , "modul_2_price": "…",
"off_peak_hours": "…", "modul_3_description": "…",
"source": {"kind": "text|pdf|url", "detail": "<file or url>", "quote": "<verbatim passage per field, abridged ok>"},
"confidence": "high|medium|low", "extracted_at": "<ISO date>"}
Missing fields stay null. NEVER invent values; a null with a note beats a guess (country hard rule 1). Numbers must appear in the source — quote them.
The modul_*_price fields are provenance, not data: keep the source's own
wording, decimal comma, unit and sign — "-121,62 EUR/Jahr netto (-144,73 brutto), pauschale Entgeltreduzierung", never a pre-parsed "-121.62". The
machine-readable figures belong in normalized below, and that is the only
place unit/sign conversion happens. (2026-08-12: runs that wrote bare numbers
here treated normalization as a copy and carried the Preisblatt's minus sign
into normalized, which the contents API rejects.)
Additionally include a normalized object in every findings record:
"normalized": {"modul1_netto_eur_a": 127.60, "modul1_brutto_eur_a": 151.84,
"modul2_netto_ct_kwh": 3.22, "modul2_brutto_ct_kwh": 3.83,
"off_peak_windows": "00:00-06:00; 22:00-00:00", "off_peak_note": "nur Q1+Q4 (or null)",
"modul3_summary": "<clean German, ≤200 chars>", "vat_derived": false,
"review_note": "<ambiguity for Stefanie, or null>"}
Normalization conventions:
- Magnitudes are POSITIVE — always, no exceptions. §14a only ever grants a
reduction, never a surcharge, so these fields hold how big the reduction is.
Preisblätter print Modul 1 as a deduction line ("-117,02 EUR/a") because it is
subtracted from the normal Netzentgelt; that minus is bookkeeping, not part of
the value. Drop it here even when you copied it verbatim into
modul_1_price, and say so inreview_note. - modul1 = the pauschale Reduzierung alone (EUR/a) — not Grundpreis/Arbeitspreis.
- modul2 = the reduced Arbeitspreis in ct/kWh. A percentage-only answer ("60% Reduzierung") without an operator number → null + review_note.
- When only netto or only brutto is given, derive the other with 19% USt
(2 decimals) and set
vat_derived: true. Unlabeled values: assume netto, note it in review_note. - off_peak_windows = the NT/Niedriglast windows only, canonical "HH:MM-HH:MM; HH:MM-HH:MM"; seasonal restrictions go to off_peak_note.
- Numbers you cannot determine stay null with a review_note — never guess.
5. Record triage
Append one line per processed message to triage.jsonl:
{"message_id": "…", "mastr_nr": "SNB… or null", "classification": "…",
"action": "extracted|followup_drafted|wait|reroute_drafted|human_queue|suppressed|none",
"detail": "ticket ref / OOO return date / new contact / reason", "ts": "<ISO>",
"wait_until": "YYYY-MM-DD or null"}
wait_until (out_of_office only): the stated return date, machine-readable —
the reminder script defers silent operators until it has passed. Null when no
date is stated.
6. Consequences per class
answer_partial: draft a short German follow-up asking ONLY the missing fields (thank them, quote reference) → append{mastr_nr, to, subject: "Re: <their subject>", body, in_reply_to: "<their message_id>"}tofollowups-proposed.jsonl. Do not send it here — step 7 flushes the whole queue at the end of the pass.reroute: draft the same inquiry to the named address → also intofollowups-proposed.jsonlwith"reroute": true(no in_reply_to — new thread).do_not_contact: append the mastr_nr (one per line) todo_not_contact.txt.bounce: nothing to send; it goes in the summary warning.question_back/wrong_entity/ unresolvable: list under "needs human" in the summary.
7. Finish
Run node campaigns/netzbetreiber/export_findings.mjs (regenerates findings.csv
— one row per operator, latest non-null value per field), then
node campaigns/netzbetreiber/push_sheet.mjs (updates the Findings/Outreach
tabs in Stefanie's Google Sheet; skip without failing the run if it errors on
missing credentials), then node campaigns/netzbetreiber/render_reminders.mjs
(drafts reminders for operators silent ≥7 days / past their out-of-office
return date — it self-skips unless the mailbox is fully triaged, so a
cap-limited run simply defers reminders to the next one), and finally
node campaigns/netzbetreiber/followups.mjs send --all — this SENDS all
pending follow-ups and reminders right now, while the mailbox is provably
fully triaged (maximum freshness). Send-time data guards supersede anything
stale (operator replied meanwhile, data complete, do_not_contact). Report
sent and superseded counts in your final message.
Fire it DETACHED and do not wait for it. The send takes ~30 minutes and is not your job to supervise — start it, confirm it started, move on:
setsid nohup node campaigns/netzbetreiber/followups.mjs send --all \
> campaigns/netzbetreiber/send-last.log 2>&1 < /dev/null &
setsid is what makes this work. A plain &, or a background tool call, stays
attached to this run and is killed the moment you produce your final message —
2026-08-24: a run did exactly that and was killed after 12 of 152 drafts, while
the ledger recorded ok. Detached, it reparents to PID 1 and keeps sending long
after the run ends. It holds a lock, so it cannot collide with the next pass.
Do not tail the log, do not poll it, do not schedule a check on yourself, and
never end your turn "waiting" for it. Confirm it started, note how many drafts
were queued, and report that. Its results land in followups-sent.jsonl; read
that at the start of the NEXT run to report what the last send actually did.
Always run this step — it is not a judgment call. Country CLAUDE.md hard
rule 2 names this script as its explicit exception; the rule forbids you writing
mail yourself, not running the sender. If the mailbox is not fully triaged, the
script self-skips and says so — that is the correct outcome. Report the skip; do
not --force it and do not withhold the send on your own initiative. If you
believe the send should not happen, say so in your final message and journal entry
rather than silently deviating (2026-08-24: two runs made opposite calls here).
Final message (goes to Google Chat): this run's triage work — counts per
classification, operators with complete data so far, what the previous pass's
send did (from followups-sent.jsonl), one line on whether you fired the
follow-up send and how many drafts were queued, and a WARNINGS block (bounces,
do_not_contact, unmatched). Aggregated, not one line per mail. Journal entry per
country CLAUDE.md.
The final message is the deliverable — it must be the results of THIS run's replies work. It is the only thing a human sees. Report what you did, with the numbers, in the past tense. The send is fire-and-forget: one line saying it was triggered is the whole of it, and its counts belong to the next run's report.
Never substitute process narration for results: no "I've dispatched…", no "I'll be notified when…", no "scheduled a fallback check", no describing tasks you started instead of work you finished. If the message has no triage numbers in it, this run reported nothing, whatever the ledger says. Same for the journal entry — write it before the final message, never skip it.