0% 5h quota used · resets in 2h live · 08/09/2026 12:12 CEST

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 against seed.csv; if still unclear → irrelevant or 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 in review_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>"} to followups-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 into followups-proposed.jsonl with "reroute": true (no in_reply_to — new thread).
  • do_not_contact: append the mastr_nr (one per line) to do_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.