Skip to content

Veilleur /vɛ.jœʁ/ · French for night watchman

From alert to a verified fix PR — while you sleep.

An always-on on-call agent for small teams without a dedicated SRE. It investigates production incidents, reproduces them in a sandbox, and brings you a verified fix to approve.

to the root cause
~20 s
correct root causes
16/16
verified fix PRs
12/12
false rollbacks
0

From a 16-run chaos eval on a live stack. How it was measured

  1. VeilleurINC-74+6 s

    🚨 Incident detected: INC-74 · sncf-mcp

    - User impact: 56 of 604 next_departures calls failed (21.47%) in the last 5 min.

    - Last deploy a80001e8c303 was at 19:43:01 UTC, about 1 minute before the alert.

  2. VeilleurINC-74+22 s

    🔎 INC-74 · sncf-mcp: NextSummary indexes an empty departures slice

    Suspect: a80001e8c303 "next_departures: lead with the next train" by Camille Martin, deployed 19:43 UTC

    - 68 next_departures calls failed on a80001e8c303; 0 of 152 calls failed on previous 26d2a883a3c3.

  3. Approval botINC-74+49 s

    🔐 INC-74 · sncf-mcp: approve merging PR #38?

    transform: handle empty departures in NextSummary

    Commit 30ec08cb64ec: the new test fails on the deployed a80001e8c303 and the full suite passes with the fix.

    Approving merges it and deploys to production. Expires at 20:14 UTC.

  4. VeilleurINC-74+84 s

    🚀 INC-74 · sncf-mcp: PR #38 merged and deployed as bb629fdcee72

    Approved by the on-call engineer; merge bb629fdcee72.

INC-74 from the recorded demo run: excerpts of the agent's real messages, timed from the alert. The full messages are below.

The 3 AM problem

The fix is rarely the hard part. The first hour is.

Small teams rarely have a dedicated SRE, so a night page lands on whoever is on call. Veilleur doesn't just retell the logs. It takes the incident to a verified fix: it investigates, reproduces the failure with a failing test, brings a PR you approve with one tap, and learns from every incident.

“I've spent years as the engineering manager getting paged at 3 AM — the hard part is never the fix, it's the first hour of figuring out what broke.”

  • Proof, not guesses

    The host itself checks that the new test fails before the fix and the whole suite is green after it.

    How it works
  • Safe by construction

    Tool permissions are enforced in code, nothing reaches production without a human tap, and an upstream outage gets no fix and no rollback.

    Safety
  • Raw logs stay home

    Redaction runs on the host, and the lab where it reproduces failures has no network.

    Privacy

What it is not: not an observability platform, not an autonomous production deployer, not multi-cloud. One narrow loop: incident → verified fix → lesson.

How it works

Four steps, from the page to a lesson

Every message below is verbatim from INC-74, one incident of the recorded demo run. Times are from the alert.
  1. 01 / 04

    Investigate

    Correlates the alert with deploys, diffs, logs and metrics, checks what it has seen before, and names a suspect commit with evidence.

    Under the hood

    Read-only tools: logs_signatures, logs_query, metrics_query, deploys_list, git_diff, memory_search.

    VeilleurINC-74+6 s

    🚨 Incident detected: INC-74 · sncf-mcp

    - User impact: 56 of 604 next_departures calls failed (21.47%) in the last 5 min.

    - find_station, plan_journey, and get_disruptions had 0 failures.

    - No non-2xx Navitia responses reported.

    - Last deploy a80001e8c303 was at 19:43:01 UTC, about 1 minute before the alert.

    Investigating.

    VeilleurINC-74+22 s

    🔎 INC-74 · sncf-mcp: NextSummary indexes an empty departures slice

    Suspect: a80001e8c303 "next_departures: lead with the next train" by Camille Martin, deployed 19:43 UTC

    Evidence:

    - 68 next_departures calls failed on a80001e8c303; 0 of 152 calls failed on previous 26d2a883a3c3.

    - Diff adds NextSummary(deps); it indexes deps[0] at internal/transform/transform.go:168.

    Failing call: next_departures {"station":"Culmont-Chalindrey"} (request lg-368862)

    Next: reproduce this call against a80001e8c303 with a failing test before proposing a fix.

    hypothesis recorded at 19 s

    The page and the root cause, INC-74.
  2. 02 / 04

    Reproduce

    Replays the failing call from the logs against the deployed commit in a container with no network, then writes a Go test that fails the same way.

    Under the hood

    sandbox_replay (~1 s) and tests_run (go test, 0.5–1 s) in Docker with --network none.

    internal/transform/next_summary_regression_test.go
    package transform
    
    import "testing"
    
    func TestNextSummaryEmpty(t *testing.T) {
    	got := NextSummary(nil)
    	if got != "No upcoming departures" {
    		t.Fatalf("NextSummary(nil) = %q, want %q", got, "No upcoming departures")
    	}
    }
    internal/transform/transform.go · the fix
    +	if len(deps) == 0 {+		return "No upcoming departures"+	} 	next := deps[0]
    The failing test Veilleur wrote in INC-74, and the fix.
  3. 03 / 04

    Propose a fix

    Opens a PR only after the host itself checked it: the new test fails on the deployed commit and the whole suite passes with the fix. You approve with one tap; nothing ships without it.

    Under the hood

    fix_propose re-runs both checks on the host; github_merge_pr is tier T3 and needs a single-use approval.

    VeilleurINC-74+55 s

    🛠 INC-74 · sncf-mcp: fix proposed, PR #38

    Reproduced: next_departures for Culmont-Chalindrey → panic: index out of range [0] with length 0 on a80001e8c303; TestNextSummaryEmpty fails there with the same panic.

    Fix: NextSummary returns “No upcoming departures” for an empty slice.

    Checks: TestNextSummaryEmpty passes and the full suite is green (52 tests) on 30ec08cb64ec.

    PR: #38

    Approval requested: merging deploys the fix; use the buttons in the approval message.

    PR opened at 47 s

    Approval botINC-74+49 s

    🔐 INC-74 · sncf-mcp: approve merging PR #38?

    transform: handle empty departures in NextSummary

    Commit 30ec08cb64ec: the new test fails on the deployed a80001e8c303 and the full suite passes with the fix.

    Approving merges it and deploys to production. Expires at 20:14 UTC.

    VeilleurINC-74+84 s

    🚀 INC-74 · sncf-mcp: PR #38 merged and deployed as bb629fdcee72

    Approved by the on-call engineer; merge bb629fdcee72.

    Next: the alerts should resolve within a few minutes; then the postmortem.

    The fix, the approval request from the separate approval bot, and the deploy.
  4. 04 / 04

    Learn

    Writes the postmortem and updates its runbook with a lesson for the whole class of failure. When the same failure comes back, it recognizes it and proposes the known fix in one turn.

    Under the hood

    postmortem_write; the runbook is Markdown in its own git repository; memory_search matches by failure signature.

    VeilleurINC-74+5 min

    📝 INC-74 · sncf-mcp: postmortem written, incident closed

    Root cause: a80001e8c303 indexed the first departure from an empty upstream result; the fix was deployed as bb629fdcee72.

    Impact: 19:43–19:46 UTC (2 min), 140 failed calls

    Took: diagnosis 19 s, reproduction 31 s, PR 47 s from the page; 21 tool calls

    Runbook: added R-1 "Assuming a collection returned by an upstream is non-empty" (03d6b0616f1b)

    Next time: Check logs_signatures, compare failures across deploys, inspect the top frame, and confirm whether the upstream returned an empty collection.

    The postmortem, with the time each step took.

Demo video

From the page to the lesson, on a phone

The demo, about 75 seconds long, is being recorded. Until then, every agent message on this page is a real one, verbatim.
Placeholder for the demo video: a dark frame with the Veilleur lantern and the words “Demo video, coming soon”.
Placeholder: the video goes here once it is recorded.

What it shows

  1. 1🚨 The page
  2. 2🔎 The root cause, and what the model saw after redaction
  3. 3🛠 The lab, the PR, one tap on Approve, 🚀 deployed
  4. 4📝 The postmortem and the runbook diff
  5. 5♻️ The same bug again
  6. 6🌐 An innocent deploy: no rollback

Proof

16 runs on a live stack. Every root cause right.

Eval of 25 Sep 2026, model gpt-6-luna: 4 chaos scenarios × 2 runs × cold and warm memory.
correct root causesevery run, cold and warm memory
16/16
host-verified fix PRsin the code-bug runs; the upstream outage correctly gets no PR
12/12
false rollbacksand 0 false fixes, 0 failed agent turns
0
to the root causein every scenario, medians 18–24 s
~20 s
to a fix PR without memorymedians 78–176 s
1–3 min
to a PR when the same failure comes backtool calls 21 → 7 (scenario 1, cold → warm)
78 → 37 s

16 runs on the live stack: real alerts, real Telegram messages, real PRs. Each of 4 chaos scenarios ran twice with an empty memory (cold) and twice with what earlier incidents taught it (warm).

Times are seconds from the moment the incident opens (the Alertmanager webhook), medians of 2 runs. From the bad deploy to the alert takes another 60–86 s: that is the Prometheus rule window, not the agent.

By scenario

Diagnosis / PR in seconds from the page, then tool calls. Medians of 2 runs.

ScenarioWhat breaksCold memoryWarm memoryMemory found
1 · Empty departures boardA teammate's commit reads the first train from a list that can be empty → panic18 s / 78 s / 2121 s / 37 s / 7same failure ♻️
2 · Tighter upstream timeoutA config change cuts the Navitia timeout from 15 s to 1.5 s; long journeys fail. Red herring: it looks like the upstream21 s / 176 s / 2624 s / 118 s / 22—
4 · Upstream outage after an innocent deployREADME-only deploy, then Navitia answers 503 for 5 min20 s / no PR / 822 s / no PR / 9—
5 · Same class, other placePanic on a journey leg without stop times (same mistake as 1, different code)23 s / 88 s / 2121 s / 80 s / 19same class (runbook R-1)

Read these numbers honestly

  • Memory speeds up the same failure, not the diagnosis: the root cause takes ~20 s even with an empty memory.
  • For the same class of failure somewhere else, the runbook entry is found and guides the investigation, but the fix is not faster (88 → 80 s).
  • 2 runs per cell: small numbers, stated as such.
  • Scenario 3 (goroutine leak) was not built; the eval covers 1, 2, 4 and 5.
  • The red herring in scenario 2 (it looks like the upstream is slow) fooled the model 0 times out of 4.
The recorded demo run, 25 Sep: three incidents in a row

Memory wiped, then three incidents, each approved in Telegram. Seconds from the alert.

IncidentMemoryPageDiagnosisPRApprove → deployedClosed
INC-74 · scenario 1 from scratchempty6 s19 s47 s54 → 81 s5 min 11 s
INC-75 · scenario 5, same classR-16 s20 s101 s124 → 145 s5 min 10 s
INC-76 · scenario 1 again ♻️INC-74, R-16 s19 s28 s45 → 64 s4 min 10 s

INC-77 (26 Sep, scenario 4 after an innocent deploy): EXTERNAL_CAUSE 22 s after the alert, no rollback; it closed by itself with a postmortem when the upstream recovered, 7.6 min after the alert.

Safety

Nothing reaches production without your tap

The limits live in the code of the tools, not only in the prompt.
Permission tiers, enforced in code
TierMeaningTools
T0Read, automatic
  • incident_get
  • incident_transition
  • logs_signatures
  • logs_query
  • metrics_query
  • deploys_list
  • git_log
  • git_diff
  • git_show_file
  • memory_search
T1Isolated actions, automatic (containers without network)
  • sandbox_replay
  • tests_run
T2Visible, reversible, automatic with notice
  • fix_propose
  • approval_request
  • postmortem_write
T3Production impact, only with a human approval
  • github_merge_pr
  • A single-use approval

    The approval is a single-use row tied to the incident and the exact commit sha, with a 30-minute TTL, spent atomically on merge. A T3 tool cannot even be registered without the approval gate.

  • Buttons the agent never touches

    Approve and Reject come from a separate approval bot that only the host polls. The agent knows only a request id, and a request id alone grants nothing. Approve pressed after the incident was closed does nothing.

  • No PR without proof

    Before pushing, the host re-runs both checks itself: the new test fails on the deployed commit, and the full suite passes on the fix.

  • Bounded loops

    Hypotheses (3), replays, test runs, fix attempts and total incident time are budgeted in code. When a budget is spent, the incident goes to ESCALATED with the evidence for a human. Reject sends it back to hypothesis and costs one of the three.

  • No false rollbacks

    An upstream outage is EXTERNAL_CAUSE: no fix, no rollback. The host refuses EXTERNAL_CAUSE when the logs show a Go change broke calls right after a deploy.

  • Survives restarts

    State lives in SQLite. Every step is idempotent, and a restart mid-incident resumes where it stopped.

Not every deploy is guilty

INC-77: a teammate's deploy changes only the README, then the upstream starts answering 503. The deploy is the obvious suspect. Veilleur names the upstream, proposes no fix and does not roll back, then closes the incident with a postmortem when the upstream recovers.

Verdict 22 s after the alert; closed by itself 7.6 min after the alert.

VeilleurINC-77+7 s

🚨 Incident detected: INC-77 · sncf-mcp

47.26% of tool calls are failing; next_departures failed 128/651 calls in the last 5 minutes.

- Failures are isolated to next_departures; find_station, plan_journey, and get_disruptions had 0 failures.

- Navitia departures returned 540 HTTP 503 responses in the last 5 minutes.

- Deploy f5aeed296103 by Léa Dubois occurred at 07:15:12 UTC, 48 seconds before the alert.

Investigating.

VeilleurINC-77+24 s

🌐 INC-77 · sncf-mcp: Navitia departures is failing, not our code

Evidence:

- Navitia returned HTTP 503 for departures ~705 times in the last 5 minutes; it also returned HTTP 200 ~476 times.

- next_departures had 165 failures on f5aeed296103; the previous commit 4fa4d56d7a12 had 0 failures across 244 calls.

- Request lg-337 failed with Navitia’s explicit unexpected status 503; f5aeed296103 changed only README.md.

No rollback: the deploy changed only documentation, and the logs show the upstream returned HTTP 503.

Next: I confirm recovery and close the incident when its alerts resolve.

verdict recorded at 22 s

VeilleurINC-77+7.7 min

✅ INC-77 · sncf-mcp: Navitia departures recovered, incident closed

- Departures returned ~260 HTTP 200s and 0 HTTP 503s in the last 2 min.

- 0 failed tool calls (~479 successful) in the last 2 min.

Impact: 07:16–07:21 UTC (5 min). No change on our side.

Runbook: added R-2 “Intermittent upstream 5xx responses” (45ecd53a4e53).

INC-77, 26 Sep: the page, the verdict, the recovery.

Privacy

Raw logs never leave the host

Everything sent to the model passes through a deterministic redaction layer on the host. Here is one real log line, before and after.
On the host · var/log/target/sncf-mcp.jsonl
{
  "time": "2026-09-26T12:35:20.559143894Z",
  "level": "INFO",
  "msg": "tool call",
  "service": "sncf-mcp",
  "commit": "fc4caadef538",
  "request_id": "lg-74672",
  "tool": "next_departures",
  "args": { "station": "Marseille Saint-Charles" },
  "client_ip": "203.0.113.24",
  "user": "manon.petit@example.fr",
  "api_key": "sncf_live_d450f297b385e17061c23dca",
  "outcome": "ok",
  "duration_ms": 1
}
What the model gets from logs_query
{
  "entries": [
    {
      "time": "2026-09-26T12:35:20.559143894Z",
      "level": "INFO",
      "msg": "tool call",
      "service": "sncf-mcp",
      "commit": "fc4caadef538",
      "request_id": "lg-74672",
      "tool": "next_departures",
      "args": { "station": "Marseille Saint-Charles" },
      "client_ip": "[ip]",
      "user": "[email]",
      "outcome": "ok",
      "duration_ms": 1,
      "dropped": ["api_key"]
    }
  ],
  "redactions": { "ipv4": 1, "email": 1 }
}

Real output of make redaction, 26 Sep. The addresses are from documentation ranges, the domains are example ones, the key is made up.

  • Layer 1 · allowlist of log fields

    A field the service starts logging tomorrow is dropped by name until someone adds it together with a redaction test.

  • Layer 2 · 11 rules on every string

    Values under secret-looking keys are replaced wholesale. Go stack traces shrink to the service's own frames: build paths, goroutines and addresses stay home. Every rule and every allowed field has its own test.

  • The lab has no network

    Replays run on an internal Docker network; tests run with --network none.

  • The model is swappable

    Any OpenAI-compatible endpoint, including a local model, can be plugged in. The demo uses OpenAI gpt-6-luna. The API key stays on the host, in llm-proxy; the sandbox never sees it.

The 11 rules

  • private keys
  • JWTs
  • bearer tokens
  • credentials in URLs
  • secret parameters
  • emails
  • phone numbers
  • IPv4
  • IPv6
  • card numbers (Luhn)
  • long opaque tokens

Architecture

The agent reasons. The host enforces.

All the business logic lives in veilleur-mcp on the host: policy, redaction, the state machine, the lab, fix verification. The agent in its sandbox calls it over MCP, so the harness is swappable.

Docker on one Mac, via OrbStack

Demo stack

  • targetsncf-mcp, a Go MCP server for French rail journeys (SNCF / Navitia)
  • fake-navitiarecorded fixtures, fault injection
  • loadgentraffic from four fictional partner apps
  • Prometheus + Alertmanagererror-rate and latency rules
  • chaos scenariosa teammate's commit, or an upstream fault
alert → Alertmanager webhook

host · TypeScript

ingress

  • Alertmanager webhook → deduplicated incident
  • captures evidence at detection
  • wake queue: one agent turn at a time, resumes after restarts
state

node:sqlite

SQLite

  • incidents, the timeline of every step, the wake queue, approvals, postmortems
  • state changes only through the state machine
wakes the agent

NemoClaw / OpenShell sandbox

the harness

OpenClaw agent

  • workspace (persona, rules) and two skills: incident-response, postmortem
  • business logic stays out of the harness, so the harness is swappable
messages

the on-call engineer

Telegram

  • the agent's messages
  • Approve / Reject from a separate approval bot, polled by the host
inference

host

llm-proxy → OpenAI gpt-6-luna

  • holds the API key; OpenShell routes the sandbox's inference to it
  • any OpenAI-compatible endpoint can replace the model
MCP

host · MCP server, 16 tools

veilleur-mcp

  • policy tiers
  • redaction
  • incident state machine
  • the lab (Docker, no network)
  • fix verification
  • GitHub PRs
  • approval bot
  • memory
  • postmortems and the runbook

Reads the stack's logs, metrics and deploys; the model sees them redacted.

PRs, merge

the demo repository that mirrors what is deployed

GitHub

  • fix PRs; merge and deploy only through the T3 tool
lessons

Markdown in its own git repository

Runbook

  • every lesson is a commit; git log -p shows what each incident taught

The incident state machine

State changes go only through it, and every step is recorded in the timeline.

  1. DETECTED
  2. TRIAGING
  3. HYPOTHESIS
  4. REPRODUCING
  5. FIX_PROPOSED
  6. AWAITING_APPROVAL
  7. MITIGATED
  8. POSTMORTEM
  9. CLOSED
    1. TRIAGING
    2. EXTERNAL_CAUSE
    3. (POSTMORTEM)
    4. CLOSED
    no fix, no rollback
    1. AWAITING_APPROVAL
    2. HYPOTHESIS
    on Reject
    1. HYPOTHESIS
    2. LOW_CONFIDENCE
    1. REPRODUCING
    2. NOT_REPRODUCED
    1. any open state
    2. ESCALATED
    3. CLOSED
    a human takes over, with the evidence

It learns

A postmortem after every incident, a lesson in its own runbook

The runbook is Markdown in its own git repository: every lesson is a commit, and git log -p shows what each incident taught.

  • After recovery, postmortem_write builds the facts on the host: times, impact, tool calls, the fix. The agent adds the judgement. An incident closes only when the service is healthy again.
  • The runbook entry is written for the class of failure, not for one file. The host rejects entries that name this incident's code.
  • Same failure (the signature matches a past incident): the known fix is proposed in one turn, and the host still re-verifies the test and the suite.
  • Same class elsewhere: the runbook entry is found and guides the investigation. This is not faster.
runbook · git log --oneline
45ecd53 INC-77: Navitia departures returned intermittent HTTP 503s
e8eb681 INC-76: …
de12c2e INC-75: plan_journey panics on an empty stop date-time list
03d6b06 INC-74: Empty departures slice crashes next-departure summaries
Runbook entry R-1, after INC-74, INC-75 and INC-76
## R-1 · sncf-mcp: Assuming an upstream collection is non-empty

Matches: panic: runtime error: index out of range [N] with length N

- When: A tool panics with an index-out-of-range signature after reading the first item from an upstream collection; the upstream may return HTTP 200 with an empty list while other tools remain healthy.
- Check first: Use `logs_signatures` to identify the affected tool and top in-repo frame; compare failure counts across deploys, inspect the relevant code, then check the failing request for an empty upstream collection.
- Reproduce: Replay a failing request from `logs_signatures` against the deployed revision; add a focused Go test in the affected package with an empty upstream collection and confirm it fails on that revision.
- Fix: Handle an empty collection explicitly before indexing or deriving a summary; return a valid empty-result response, then run the focused regression test and the full Go suite.
- Avoid: Do not infer that a successful HTTP status means the response collection is non-empty; check empty-result cases before investigating unrelated tools.
- Seen: INC-74, INC-75, INC-76

Seen it before

When the same failure comes back, Veilleur recognizes it and proposes the known fix in one turn. The host still re-verifies the test and the suite.

to a PR, cold → warm
78 → 37 s
tool calls, cold → warm
21 → 7

Eval medians, scenario 1. Memory speeds up the same failure only: for the same class elsewhere the runbook guides the investigation, but the fix is not faster (88 → 80 s).

VeilleurINC-76+32 s

♻️ INC-76 · sncf-mcp: known failure from INC-74 is back, fix proposed, PR #40

Suspect: 2f77c49a2121 "next_departures: lead with the next train" by Camille Martin, deployed 20:00 UTC

Evidence:

- 89 next_departures panics on 2f77c49a2121; 0 failures in 594 calls on 26d2a883a3c3.

- Same failure as INC-74: empty departures slice causes NextSummary to index deps[0].

Checks: TestNextSummaryEmpty fails on 2f77c49a2121; the full suite passes with the fix (52 tests), verified by the host.

PR opened at 28 s

VeilleurINC-76+4 min

📝 INC-76 · sncf-mcp: postmortem written, incident closed

Root cause: Commit 2f77c49a2121 reintroduced an unchecked first-departure index; a valid empty Navitia response triggered next_departures panics.

Impact: 20:01–20:02 UTC (2 min), 133 failed calls

Took: diagnosis 19 s, reproduction 23 s, PR 28 s from the page; 12 tool calls

Runbook: updated R-1 "Assuming an upstream collection is non-empty" (e8eb681a1949)

Next time: Check logs_signatures, compare failures across deploys, then confirm whether the failing upstream collection is empty.

INC-76 in the recorded demo run: the same bug again, with INC-74 in memory.

Built with

Built solo, in about ten days

  • OpenClaw
  • NemoClaw
  • OpenShell
  • Model Context Protocol (TypeScript SDK)
  • Node.js
  • SQLite
  • Docker
  • Prometheus
  • Alertmanager
  • Go
  • Telegram Bot API
  • GitHub API
  • OpenAI gpt-6-luna

Ilya Seliverstov built Veilleur alone for the NVIDIA Paris Claw Agent Challenge, in the OpenClaw harness category: OpenClaw inside NemoClaw / OpenShell.