Step 1: Wake the agent with a signed webhook
The process config is in incident_agent/config.py:
incident_agent/config.pypython
triggers=[ TriggerConfig( type="webhook", webhook_path=WEBHOOK_PATH, webhook_port=webhook_port, hmac_secret=webhook_secret, # Alertmanager also sends "resolved" notifications; only a firing alert needs work. filter_expression="payload.status == 'firing'", )],
The runtime wraps the payload in a block marked as untrusted data. Text inside an alert is information, not an order. With JournalConfig(level="full"), the journal records a trigger_event with the webhook's event id. Then come the run's tool calls and its result.
Alertmanager can send a bearer token, but it can't sign the body. scripts/alertmanager_relay.py checks the token. Then it signs the body and forwards it. Point Alertmanager's webhook_configs at the relay, with http_config.authorization set to the token.
Step 2: Put your ops systems behind an authenticated MCP server
One MCP server built with the Promptise MCP server SDK stands in for Loki, Prometheus, Argo CD, the runbook wiki, the incident tool and Slack. It runs as its own HTTP service. Every caller presents an API key. The key decides the roles. This is from ops_systems/server.py:
ops_systems/server.pypython
server = MCPServer(name="ops", version="1.0.0", require_auth=True)# Audit first, so refused calls (bad key, wrong role, rate limit) are recorded too.# Set PROMPTISE_AUDIT_SECRET to sign the log as an HMAC chain.signed = bool(os.environ.get("PROMPTISE_AUDIT_SECRET"))server.add_middleware( AuditMiddleware(log_path=str(Path(state_dir) / "ops-audit.jsonl"), signed=signed, include_args=True))server.add_middleware(AuthMiddleware(APIKeyAuth(keys=api_keys)))
The tools that change production need the remediator role. They are also rate-limited per client:
ops_systems/server.pypython
@server.tool(roles=REMEDIATE, rate_limit=CHANGE_RATE, destructive_hint=True)async def rollback_deploy( service: Service, to_version: Annotated[str, Field(description='The version to go back to, for example "v2.13.4".')], reason: Reason,) -> dict: """Roll a service back to an earlier deployed version. Needs approval.""" return run(world.rollback_deploy, service, to_version, reason)
A status page holding a viewer key can read logs. It gets ACCESS_DENIED on a rollback. The agent connects with its own key: servers={"ops": {"url": ops_url, "api_key": ops_api_key}}.
Step 3: Hold every change for a person
incident_agent/config.pypython
approval=ApprovalPolicy( tools=RISKY_TOOLS, handler=reviewer, timeout=approval_timeout, on_timeout="deny", on_decision=trace.on_decision,),
RISKY_TOOLS names restart_service, rollback_deploy and scale_service. The call stops at the gate. The reviewer sees the tool, the arguments and the agent's reason. A denial comes back to the model as DENIED: <reason>. No answer within 15 minutes counts as a denial. TerminalReviewer in incident_agent/reviewers.py asks at the keyboard. In production you swap in a WebhookApprovalHandler to your approval service.
Step 4: Cap what one alert may do
incident_agent/config.pypython
budget=BudgetConfig( enabled=True, # One production change per alert. A second one is refused before it reaches the reviewer. max_irreversible_per_run=1, tool_costs={name: ToolCostAnnotation(cost_weight=5.0, irreversible=True) for name in RISKY_TOOLS}, # A runaway loop stops long before it hurts; a normal incident takes 12 to 20 calls. max_tool_calls_per_run=40, # An alert storm can't run up the model bill. max_runs_per_day=100, # Any of these crossed: the process pauses until a person resumes it. on_exceeded="pause",),
An approval answers "is this change right?". The budget answers "how many changes may one alert make?". A denied call gives its irreversible action back, so a denial never uses up the alert's one change.
Step 5: Write the instructions as code
The system prompt is assembled from promptise.prompts.blocks in incident_agent/prompts.py. Each block is a value you can test and diff:
incident_agent/prompts.pypython
def incident_prompt() -> PromptAssembler: return PromptAssembler(IDENTITY, SAFETY_RULES, RUNBOOK_CONTRACT, PHASES, FINAL_REPLY)
SAFETY_RULES holds the rules that keep production safe. RUNBOOK_CONTRACT says how to read a runbook. The agent does every Investigate step. It picks the first Mitigate condition its findings meet. It compares the Verify metric with its threshold. tests/test_prompts.py checks that the safety rules are in the rendered text.
The real runs shaped these rules. In the first run the model posted its findings only after the rollback. A rule now says to post before calling a fix. It also wrote a long final reply. A rule now caps it at three sentences.
Step 6: Remember past incidents per service
incident_agent/memory.py keeps lessons in a promptise.memory.InMemoryProvider with MemoryScope.PER_USER. The scope key is the service name. A search for checkout-service can never return a cart-service incident. The agent reads it through a tool:
incident_agent/memory.pypython
@toolasync def recall_past_incidents(service: str, query: str = "") -> list[dict]: """Past incidents on one service, with their root cause and follow-ups. Args: service: The service name, for example "checkout-service". query: Optional words to rank by, for example "deploy timeout". Empty lists the latest. """ return await memory.recall(service, query)
A task_completed runtime hook stores each finished incident summary. A copy goes to memory.jsonl, so the memory survives a restart. In a later real run the agent recalled the earlier INC-1042 with its root cause.
Step 7: Record every step
incident_agent/config.pypython
observe=ObservabilityConfig( level=ObserveLevel.FULL, session_name="incident-agent", transporters=[TransporterType.CALLBACK, TransporterType.HTML], on_event=trace.on_event, output_dir=str(state_dir / "traces"),),
IncidentTrace in incident_agent/trace.py groups the timeline by run. It writes an NDJSON file and a readable timeline. The approval decisions come from on_decision. The incident summary merges the agent's findings with the incident tool's own record of status posts and changes. The timeline then never depends on the model's memory.
Test it
promptise run test runs 35 offline tests against the real process, webhook, relay and MCP server. Only the model is scripted. This test from tests/test_incident_flow.py proves the budget:
tests/test_incident_flow.pypython
# The second change never reached the reviewer or production.assert [r.tool_name for r in reviewer.requests] == ["rollback_deploy"]assert [a["action"] for a in run.world["actions"]] == ["rollback"]assert "max_irreversible_per_run" in run.tool_messages("restart_service")[0]
promptise run test-live runs one incident on your model key. It checks that the agent posted before it asked for the fix. It also checks that the rollback went through the gate.