diff --git a/apps/dashboard/src/app/api/chat/route.ts b/apps/dashboard/src/app/api/chat/route.ts
index bd135dbf..b6ca81a9 100644
--- a/apps/dashboard/src/app/api/chat/route.ts
+++ b/apps/dashboard/src/app/api/chat/route.ts
@@ -3,6 +3,7 @@ import {
type ChatStoredMessage,
storedMessageSchema,
} from "@openstatus/db/src/schema";
+import { isFeatureEnabled } from "@openstatus/services";
import {
agentTools,
buildAgentSystemPrompt,
@@ -154,6 +155,10 @@ export async function POST(req: NextRequest) {
workspaceName: ctx.workspace.name ?? "Unknown",
surface: "dashboard",
canNotifySubscribers: ctx.workspace.limits["status-subscribers"] === true,
+ incidentManagement: isFeatureEnabled(
+ ctx.workspace,
+ "incident-management",
+ ),
}),
messages: modelMessages,
tools,
diff --git a/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx
new file mode 100644
index 00000000..870dabdf
--- /dev/null
+++ b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx
@@ -0,0 +1,131 @@
+import type {
+ AgentToolInput,
+ AgentToolOutput,
+} from "@openstatus/services/agent-tools";
+
+import type { ChangeRow } from "@/components/common/changes-table";
+import { TableCellDate } from "@/components/data-table/table-cell-date";
+import { TableCellNumber } from "@/components/data-table/table-cell-number";
+import { TableCellText } from "@/components/data-table/table-cell-text";
+
+import type { DetailsTableData } from "./details-table";
+import type { ResultTableData } from "./result-table";
+
+export function listIncidentsTable(
+ output: AgentToolOutput<"list_incidents">,
+): ResultTableData<"title" | "severity" | "status" | "commander" | "id"> {
+ const items = output?.items ?? [];
+ return {
+ empty: "No incidents.",
+ columns: [
+ { key: "title", header: "Title" },
+ { key: "severity", header: "Severity" },
+ { key: "status", header: "Status" },
+ { key: "commander", header: "Commander" },
+ { key: "id", header: "ID" },
+ ],
+ rows: items.map((i) => ({
+ id: i.id,
+ cells: {
+ title: ,
+ severity: ,
+ status: (
+
+ ),
+ commander: ,
+ id: ,
+ },
+ })),
+ };
+}
+
+export function getIncidentDetails(
+ output: AgentToolOutput<"get_incident">,
+): DetailsTableData {
+ return {
+ sections: [
+ {
+ rows: [
+ { label: "ID", value: },
+ { label: "Title", value: },
+ {
+ label: "Severity",
+ value: ,
+ },
+ { label: "Status", value: },
+ {
+ label: "Commander",
+ value: ,
+ },
+ {
+ label: "Started",
+ value: ,
+ },
+ {
+ label: "Status report",
+ value: ,
+ },
+ ],
+ },
+ {
+ title: "Timeline",
+ rows: output.events.slice(0, 10).map((e) => ({
+ label: e.type.replaceAll("_", " "),
+ value: ,
+ })),
+ },
+ ],
+ };
+}
+
+export function declareIncidentChanges(
+ input: AgentToolInput<"declare_incident">,
+ result?: { id: number },
+): ChangeRow[] {
+ const changes: ChangeRow[] = [];
+ if (result) changes.push({ field: "id", after: result.id });
+ changes.push(
+ { field: "title", after: input.title },
+ { field: "severity", after: input.severity },
+ );
+ if (input.summary) changes.push({ field: "summary", after: input.summary });
+ if (input.commanderId !== undefined) {
+ changes.push({ field: "commanderId", after: input.commanderId });
+ }
+ if (input.startedAt) {
+ changes.push({ field: "startedAt", after: input.startedAt });
+ }
+ if (input.statusReportId !== undefined) {
+ changes.push({ field: "statusReportId", after: input.statusReportId });
+ }
+ return changes;
+}
+
+export function updateIncidentChanges(
+ input: AgentToolInput<"update_incident">,
+): ChangeRow[] {
+ const changes: ChangeRow[] = [{ field: "incidentId", after: input.id }];
+ for (const field of [
+ "title",
+ "severity",
+ "summary",
+ "commanderId",
+ "startedAt",
+ ] as const) {
+ if (input[field] !== undefined) {
+ changes.push({ field, after: input[field] });
+ }
+ }
+ return changes;
+}
+
+export function resolveIncidentChanges(
+ input: AgentToolInput<"resolve_incident">,
+): ChangeRow[] {
+ const changes: ChangeRow[] = [
+ { field: "incidentId", after: input.id },
+ { field: "status", after: "resolved" },
+ ];
+ if (input.note) changes.push({ field: "note", after: input.note });
+ return changes;
+}
diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx
index bd843473..65ff7a0a 100644
--- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx
+++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx
@@ -19,6 +19,13 @@ import { getMonitorDetails } from "./get-monitor";
import { getMonitorStatusTable } from "./get-monitor-status";
import { getMonitorSummaryDetails } from "./get-monitor-summary";
import { getResponseLogDetails } from "./get-response-log";
+import {
+ declareIncidentChanges,
+ getIncidentDetails,
+ listIncidentsTable,
+ resolveIncidentChanges,
+ updateIncidentChanges,
+} from "./incidents";
import { listAuditLogsTable } from "./list-audit-logs";
import { listMaintenancesTable } from "./list-maintenances";
import { listMonitorsTable } from "./list-monitors";
@@ -141,6 +148,42 @@ export const toolRenderers: ToolRendererRegistry = {
),
summary: (o) => `ID ${o.id}`,
},
+ list_incidents: {
+ renderResult: ({ output }) => (
+
+ ),
+ summary: (o) => itemsCountSummary(o.items),
+ },
+ get_incident: {
+ renderResult: ({ output }) => (
+
+ ),
+ summary: (o) => `${o.severity} · ${o.status}`,
+ },
+ declare_incident: {
+ renderDraft: (input) => declareIncidentChanges(input),
+ renderResult: ({ input, output }) => (
+
+ ),
+ summary: (o) => `ID ${o.id}`,
+ },
+ update_incident: {
+ renderDraft: (input) => updateIncidentChanges(input),
+ renderResult: ({ input }) => (
+
+ ),
+ summary: (o) => `ID ${o.id}`,
+ },
+ resolve_incident: {
+ renderDraft: (input) => resolveIncidentChanges(input),
+ renderResult: ({ input }) => (
+
+ ),
+ summary: (o) => `resolved · ID ${o.id}`,
+ },
+ add_incident_note: {
+ summary: (o) => `note added to incident ${o.incidentId}`,
+ },
list_monitors: {
renderResult: ({ output }) => (
diff --git a/apps/dashboard/src/lib/agent-tools/adapter.ts b/apps/dashboard/src/lib/agent-tools/adapter.ts
index c4ba216b..aa3b1879 100644
--- a/apps/dashboard/src/lib/agent-tools/adapter.ts
+++ b/apps/dashboard/src/lib/agent-tools/adapter.ts
@@ -1,7 +1,8 @@
import type { ServiceContext } from "@openstatus/services";
-import type {
- AgentToolRegistry,
- AnyAgentTool,
+import {
+ type AgentToolRegistry,
+ type AnyAgentTool,
+ isAgentToolAvailable,
} from "@openstatus/services/agent-tools";
import { type Tool, tool } from "ai";
@@ -27,7 +28,7 @@ export function toAiSdkTools(
const out: Record = {};
for (const name in registry) {
const t = registry[name];
- if (!t) continue;
+ if (!t || !isAgentToolAvailable(t, ctx.workspace)) continue;
out[name] = toolToAiSdkTool(t, ctx);
}
return out;
diff --git a/apps/server/src/libs/test/doubles/page-urls.mock.ts b/apps/server/src/libs/test/doubles/page-urls.mock.ts
index b0dae007..7d5f4965 100644
--- a/apps/server/src/libs/test/doubles/page-urls.mock.ts
+++ b/apps/server/src/libs/test/doubles/page-urls.mock.ts
@@ -32,3 +32,6 @@ export const getStatusReportLink = (
title: `Report ${statusReportId}`,
url: `https://example.openstatus.dev/events/report/${statusReportId}`,
});
+
+export const getIncidentDashboardUrl = (incidentId: number): string =>
+ `https://app.openstatus.dev/incidents/${incidentId}`;
diff --git a/apps/server/src/routes/mcp/evals/cases.ts b/apps/server/src/routes/mcp/evals/cases.ts
index a3577f8f..5508816f 100644
--- a/apps/server/src/routes/mcp/evals/cases.ts
+++ b/apps/server/src/routes/mcp/evals/cases.ts
@@ -51,6 +51,21 @@ export const cases: EvalCase[] = [
expectedTool: "list_maintenances",
},
+ // Managed incidents: internal, distinct from status reports
+ {
+ id: "select.declare_incident",
+ prompt:
+ "Declare an incident: checkout is failing for everyone, critical severity.",
+ expectedTool: "declare_incident",
+ requiredArgs: ["title", "severity"],
+ },
+ {
+ id: "prereq.note_lists_incidents_first",
+ prompt:
+ "Add a note to the checkout incident that we rolled back the deploy.",
+ expectedTool: "list_incidents",
+ },
+
// Prereq compliance: must call list_status_pages first when creating
{
id: "prereq.create_report_lists_pages_first",
diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts
index 872cb32c..6a180317 100644
--- a/apps/server/src/routes/mcp/handler.test.ts
+++ b/apps/server/src/routes/mcp/handler.test.ts
@@ -119,17 +119,21 @@ describe("MCP transport", () => {
const tools = (body.result as { tools: { name: string }[] }).tools;
const names = tools.map((t) => t.name).sort();
expect(names).toEqual([
+ "add_incident_note",
"add_status_report_update",
"create_maintenance",
"create_status_report",
+ "declare_incident",
"get_audit_log",
"get_content_page",
"get_doc_page",
+ "get_incident",
"get_monitor",
"get_monitor_status",
"get_monitor_summary",
"get_response_log",
"list_audit_logs",
+ "list_incidents",
"list_maintenances",
"list_monitors",
"list_notifications",
@@ -138,9 +142,11 @@ describe("MCP transport", () => {
"list_response_logs",
"list_status_pages",
"list_status_reports",
+ "resolve_incident",
"resolve_status_report",
"search_content",
"search_docs",
+ "update_incident",
"update_status_report",
]);
});
diff --git a/apps/server/src/routes/mcp/server.ts b/apps/server/src/routes/mcp/server.ts
index 28005969..ec14c2b1 100644
--- a/apps/server/src/routes/mcp/server.ts
+++ b/apps/server/src/routes/mcp/server.ts
@@ -5,6 +5,7 @@ import packageJson from "../../../package.json" with { type: "json" };
import { registerPublicResources } from "./resources";
import { registerAuditTools } from "./tools/audit";
import { registerContentTools } from "./tools/content";
+import { registerIncidentTools } from "./tools/incident";
import { registerMaintenanceTools } from "./tools/maintenance";
import { registerMonitorTools } from "./tools/monitor";
import { registerNotificationTools } from "./tools/notification";
@@ -31,6 +32,7 @@ export function createMcpServer(ctx: ServiceContext): McpServer {
registerPublicResources(server);
registerPageTools(server, ctx);
registerStatusReportTools(server, ctx);
+ registerIncidentTools(server, ctx);
registerMaintenanceTools(server, ctx);
registerMonitorTools(server, ctx);
registerNotificationTools(server, ctx);
diff --git a/apps/server/src/routes/mcp/tools/incident.ts b/apps/server/src/routes/mcp/tools/incident.ts
new file mode 100644
index 00000000..ae11e998
--- /dev/null
+++ b/apps/server/src/routes/mcp/tools/incident.ts
@@ -0,0 +1,29 @@
+import type {
+ McpServer,
+ RegisteredTool,
+} from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { ServiceContext } from "@openstatus/services";
+import {
+ addIncidentNoteTool,
+ declareIncidentTool,
+ getIncidentTool,
+ listIncidentsTool,
+ resolveIncidentTool,
+ updateIncidentTool,
+} from "@openstatus/services/agent-tools";
+
+import { registerRegistryTools } from "./registry-adapter";
+
+export function registerIncidentTools(
+ server: McpServer,
+ ctx: ServiceContext,
+): Map {
+ return registerRegistryTools(server, ctx, [
+ listIncidentsTool,
+ getIncidentTool,
+ declareIncidentTool,
+ updateIncidentTool,
+ resolveIncidentTool,
+ addIncidentNoteTool,
+ ]);
+}
diff --git a/apps/server/src/routes/mcp/tools/registry-adapter.ts b/apps/server/src/routes/mcp/tools/registry-adapter.ts
index e069638f..982d436a 100644
--- a/apps/server/src/routes/mcp/tools/registry-adapter.ts
+++ b/apps/server/src/routes/mcp/tools/registry-adapter.ts
@@ -3,7 +3,10 @@ import type {
RegisteredTool,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ServiceContext } from "@openstatus/services";
-import type { AnyAgentTool } from "@openstatus/services/agent-tools";
+import {
+ type AnyAgentTool,
+ isAgentToolAvailable,
+} from "@openstatus/services/agent-tools";
import { ZodObject, type ZodRawShape, type ZodType } from "zod";
import { runTool } from "../adapter";
@@ -24,6 +27,7 @@ export function registerRegistryTool(
ctx: ServiceContext,
tool: AnyAgentTool,
): RegisteredTool | undefined {
+ if (!isAgentToolAvailable(tool, ctx.workspace)) return undefined;
const inputShape = assertShape(tool.inputSchema, tool.name, "input");
const outputShape = assertShape(tool.outputSchema, tool.name, "output");
diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts
index dceeb448..ff191e87 100644
--- a/apps/server/src/routes/slack/agent.ts
+++ b/apps/server/src/routes/slack/agent.ts
@@ -1,5 +1,5 @@
import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation";
-import type { ServiceContext } from "@openstatus/services";
+import { isFeatureEnabled, type ServiceContext } from "@openstatus/services";
import { stepCountIs, streamText } from "ai";
import type { ModelMessage, Tool } from "ai";
@@ -111,7 +111,9 @@ export async function runAgent(
const result = streamText({
model: MODEL,
- system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote),
+ system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote, {
+ incidentManagement: isFeatureEnabled(workspace, "incident-management"),
+ }),
messages,
tools,
stopWhen: stepCountIs(MAX_STEPS),
diff --git a/apps/server/src/routes/slack/page-urls.ts b/apps/server/src/routes/slack/page-urls.ts
index 02ce1f95..7699a0d1 100644
--- a/apps/server/src/routes/slack/page-urls.ts
+++ b/apps/server/src/routes/slack/page-urls.ts
@@ -29,6 +29,10 @@ function getDashboardBaseUrl(): string {
: "http://localhost:3001";
}
+export function getIncidentDashboardUrl(incidentId: number): string {
+ return `${getDashboardBaseUrl()}/incidents/${incidentId}`;
+}
+
/**
* Resolve a page id to its dashboard link — the page title plus the internal
* dashboard URL (not the public status page). Scoped to the workspace so a
diff --git a/apps/server/src/routes/slack/presenters/incident.ts b/apps/server/src/routes/slack/presenters/incident.ts
new file mode 100644
index 00000000..7842bb35
--- /dev/null
+++ b/apps/server/src/routes/slack/presenters/incident.ts
@@ -0,0 +1,27 @@
+import type {
+ AgentToolInput,
+ AgentToolOutput,
+} from "@openstatus/services/agent-tools";
+
+import { getIncidentDashboardUrl } from "../page-urls";
+import type { Presenter } from "./types";
+
+function link(id: number): string {
+ return `<${getIncidentDashboardUrl(id)}|Open in openstatus>`;
+}
+
+export const declareIncidentPresenter: Presenter = ({ output }) => {
+ const o = output as AgentToolOutput<"declare_incident">;
+ return `:rotating_light: Incident *${o.title}* declared (${o.severity}).\n${link(o.id)}`;
+};
+
+export const updateIncidentPresenter: Presenter = ({ output }) => {
+ const o = output as AgentToolOutput<"update_incident">;
+ return `:white_check_mark: Incident *${o.title}* updated.\n${link(o.id)}`;
+};
+
+export const resolveIncidentPresenter: Presenter = ({ input, output }) => {
+ const i = input as AgentToolInput<"resolve_incident">;
+ const o = output as AgentToolOutput<"resolve_incident">;
+ return `:white_check_mark: Incident *${o.title}* resolved.${i.note ? `\n> ${i.note}` : ""}\n${link(o.id)}`;
+};
diff --git a/apps/server/src/routes/slack/presenters/index.ts b/apps/server/src/routes/slack/presenters/index.ts
index fcbcce7f..c53ee796 100644
--- a/apps/server/src/routes/slack/presenters/index.ts
+++ b/apps/server/src/routes/slack/presenters/index.ts
@@ -2,6 +2,11 @@ import type { ServiceContext } from "@openstatus/services";
import type { AnyAgentTool } from "@openstatus/services/agent-tools";
import { defaultPresenter } from "./default";
+import {
+ declareIncidentPresenter,
+ resolveIncidentPresenter,
+ updateIncidentPresenter,
+} from "./incident";
import { createMaintenancePresenter } from "./maintenance";
import {
addStatusReportUpdatePresenter,
@@ -17,6 +22,9 @@ export const presenters: Record = {
update_status_report: updateStatusReportPresenter,
resolve_status_report: resolveStatusReportPresenter,
create_maintenance: createMaintenancePresenter,
+ declare_incident: declareIncidentPresenter,
+ update_incident: updateIncidentPresenter,
+ resolve_incident: resolveIncidentPresenter,
};
export async function renderToolResult(args: {
diff --git a/apps/server/src/routes/slack/registry-runner.test.ts b/apps/server/src/routes/slack/registry-runner.test.ts
index d0854fad..bd1c0a57 100644
--- a/apps/server/src/routes/slack/registry-runner.test.ts
+++ b/apps/server/src/routes/slack/registry-runner.test.ts
@@ -379,10 +379,16 @@ describe("buildSystemPrompt coverage", () => {
// buildSlackTools hands the model every registry tool; a tool the prompt
// never mentions is one the model won't reach for (or will misuse).
test("mentions every tool the Slack agent is given", () => {
- const prompt = buildSystemPrompt("Acme Corp");
+ const prompt = buildSystemPrompt("Acme Corp", undefined, {
+ incidentManagement: true,
+ });
const missing = Object.keys(agentTools).filter(
(name) => !new RegExp(`\\b${name}\\b`).test(prompt),
);
expect(missing).toEqual([]);
});
+
+ test("leaves incident tools out when the feature is off", () => {
+ expect(buildSystemPrompt("Acme Corp")).not.toContain("declare_incident");
+ });
});
diff --git a/apps/server/src/routes/slack/registry-runner.ts b/apps/server/src/routes/slack/registry-runner.ts
index 20ed0f87..2d46f8f6 100644
--- a/apps/server/src/routes/slack/registry-runner.ts
+++ b/apps/server/src/routes/slack/registry-runner.ts
@@ -3,6 +3,7 @@ import {
type AgentToolName,
type AnyAgentTool,
agentTools,
+ isAgentToolAvailable,
} from "@openstatus/services/agent-tools";
import { type Tool, tool } from "ai";
import { ZodObject, type ZodType, type z } from "zod";
@@ -45,7 +46,9 @@ export function buildSlackTools(
): Record {
const out: Record = {};
for (const name of Object.keys(agentTools) as AgentToolName[]) {
- out[name] = buildTool(agentTools[name] as AnyAgentTool, ctx);
+ const t: AnyAgentTool = agentTools[name];
+ if (!isAgentToolAvailable(t, ctx.workspace)) continue;
+ out[name] = buildTool(t, ctx);
}
return { ...out, ...extras };
}
diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts
index 3fd272e0..dd302905 100644
--- a/apps/server/src/routes/slack/system-prompt.ts
+++ b/apps/server/src/routes/slack/system-prompt.ts
@@ -1,8 +1,18 @@
// dependency-free leaf so the prompt test doesn't link the agent's module
// graph (ai + services) — bun test flakes on concurrent linking of large graphs.
+const INCIDENT_SECTION = `
+
+Managed incidents (internal, never published):
+- Three things are called "incident". A managed incident (list_incidents, get_incident, declare_incident, update_incident, resolve_incident, add_incident_note) is the team's internal record with a severity, a commander and a timeline. A status report is the PUBLIC communication. Monitor downtime is detected automatically.
+- "declare an incident", "open an incident" -> declare_incident (approval card, publishes nothing). Then offer a status report, passing incidentId to create_status_report to link them.
+- "note that…", "add to the timeline" -> add_incident_note (runs right away, no card).
+- "the incident is resolved" with a managed incident in play -> resolve_incident; if its linked status report is still open, draft resolve_status_report too.
+- Before referencing a managed incident, call list_incidents. Severity: critical = major outage or data loss, major = significant degradation, minor = limited impact.`;
+
export function buildSystemPrompt(
workspaceName: string,
contextNote?: string,
+ options: { incidentManagement?: boolean } = {},
): string {
// Intentional: a per-call ISO timestamp defeats Anthropic/Gateway
// prompt caching, but the agent needs minute-level precision to parse
@@ -108,5 +118,5 @@ Maintenance scheduling:
- Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps.
- If the user doesn't specify a timezone, default to UTC and mention that in your response.
- The "from" time must be before the "to" time.
-- Write a professional maintenance message describing what will happen during the window.${contextNote ? `\n\n${contextNote.trim()}` : ""}`;
+- Write a professional maintenance message describing what will happen during the window.${options.incidentManagement ? INCIDENT_SECTION : ""}${contextNote ? `\n\n${contextNote.trim()}` : ""}`;
}
diff --git a/packages/services/src/agent-tools/__tests__/incident.test.ts b/packages/services/src/agent-tools/__tests__/incident.test.ts
new file mode 100644
index 00000000..b6d3dbcd
--- /dev/null
+++ b/packages/services/src/agent-tools/__tests__/incident.test.ts
@@ -0,0 +1,84 @@
+import { expect } from "@std/expect";
+import { beforeAll, describe, test } from "@std/testing/bdd";
+
+import {
+ createWorkspaceFixture,
+ makeUserCtx,
+ withTestTransaction,
+} from "../../../test/helpers";
+import type { Workspace } from "../../types";
+import {
+ addIncidentNoteTool,
+ agentTools,
+ declareIncidentTool,
+ getIncidentTool,
+ isAgentToolAvailable,
+ listIncidentsTool,
+ resolveIncidentTool,
+ updateIncidentTool,
+} from "../index";
+
+let workspace: Workspace;
+let userId: number;
+
+beforeAll(async () => {
+ const fixture = await createWorkspaceFixture("team");
+ workspace = fixture.workspace;
+ userId = fixture.userId;
+});
+
+describe("incident agent tools", () => {
+ test("declare defaults the commander to the requester, then the full loop", async () => {
+ await withTestTransaction(async (tx) => {
+ const ctx = { ...makeUserCtx(workspace, { userId }), db: tx };
+ const declared = await declareIncidentTool.run({
+ ctx,
+ input: declareIncidentTool.inputSchema.parse({
+ title: "Checkout failing",
+ severity: "critical",
+ }),
+ });
+ await updateIncidentTool.run({
+ ctx,
+ input: { id: declared.id, severity: "major" },
+ });
+ await addIncidentNoteTool.run({
+ ctx,
+ input: { id: declared.id, message: "Rolled back" },
+ });
+ const resolved = await resolveIncidentTool.run({
+ ctx,
+ input: { id: declared.id, note: "Fixed" },
+ });
+ expect(resolved.status).toBe("resolved");
+
+ const list = await listIncidentsTool.run({
+ ctx,
+ input: listIncidentsTool.inputSchema.parse({}),
+ });
+ expect(list.items.map((i) => i.id)).toContain(declared.id);
+
+ const detail = await getIncidentTool.run({
+ ctx,
+ input: { id: declared.id },
+ });
+ expect(detail.commander?.id).toBe(userId);
+ expect(detail.severity).toBe("major");
+ expect(detail.events.map((e) => e.type)).toEqual([
+ "resolved",
+ "note",
+ "severity_changed",
+ "declared",
+ ]);
+ expect(getIncidentTool.outputSchema.parse(detail)).toBeDefined();
+ });
+ });
+
+ test("every incident tool is feature-gated, the rest are not", () => {
+ for (const tool of Object.values(agentTools)) {
+ const isIncident = tool.name.includes("incident");
+ expect(tool.feature === "incident-management").toBe(isIncident);
+ }
+ expect(isAgentToolAvailable(listIncidentsTool, { id: 1 })).toBe(true);
+ });
+});
diff --git a/packages/services/src/agent-tools/incident.ts b/packages/services/src/agent-tools/incident.ts
new file mode 100644
index 00000000..0b28acde
--- /dev/null
+++ b/packages/services/src/agent-tools/incident.ts
@@ -0,0 +1,376 @@
+import {
+ incidentSeverity,
+ incidentStatus,
+} from "@openstatus/db/src/schema/incidents/constants";
+import { z } from "zod";
+
+import { tryGetActorUserId } from "../context";
+import { NotFoundError } from "../errors";
+import {
+ addIncidentNote,
+ declareIncident,
+ displayName,
+ getIncident,
+ listIncidentEvents,
+ listIncidents,
+ setIncidentStatus,
+ updateIncident,
+} from "../incident";
+import type { AgentTool, SummaryLine } from "./types";
+
+const FEATURE = "incident-management";
+
+const personSchema = z.object({ id: z.number().int(), name: z.string() });
+
+type Person = {
+ id: number;
+ name: string | null;
+ firstName: string | null;
+ lastName: string | null;
+ email: string | null;
+} | null;
+
+function person(row: Person) {
+ return row ? { id: row.id, name: displayName(row) } : null;
+}
+
+const IncidentSummary = z.object({
+ id: z.number().int(),
+ title: z.string(),
+ severity: z.enum(incidentSeverity),
+ status: z.enum(incidentStatus),
+ closed: z.boolean(),
+ commander: personSchema.nullable(),
+ declaredAt: z.string(),
+ startedAt: z.string(),
+ statusReportId: z.number().int().nullable(),
+ slackChannelId: z.string().nullable(),
+});
+
+const ListIncidentsInput = z.object({
+ status: z
+ .array(z.enum(incidentStatus))
+ .optional()
+ .describe("Only incidents in these statuses. Omit for all."),
+ limit: z.number().int().min(1).max(100).default(20),
+});
+
+const ListIncidentsOutput = z.object({ items: z.array(IncidentSummary) });
+
+export const listIncidentsTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "list_incidents",
+ description:
+ "List managed incidents in this workspace (declared by the team, not monitor downtime), open ones first.",
+ scope: "read",
+ destructive: false,
+ feature: FEATURE,
+ inputSchema: ListIncidentsInput,
+ outputSchema: ListIncidentsOutput,
+ async run({ ctx, input }) {
+ const rows = await listIncidents({
+ ctx,
+ input: { status: input.status, limit: input.limit },
+ });
+ return {
+ items: rows.map((row) => ({
+ id: row.id,
+ title: row.title,
+ severity: row.severity,
+ status: row.status,
+ closed: row.closedAt !== null,
+ commander: person(row.commander),
+ declaredAt: row.declaredAt.toISOString(),
+ startedAt: row.startedAt.toISOString(),
+ statusReportId: row.statusReportId,
+ slackChannelId: row.slackChannelId,
+ })),
+ };
+ },
+};
+
+const GetIncidentInput = z.object({
+ id: z.number().int().describe("Incident id, from list_incidents."),
+});
+
+const GetIncidentOutput = IncidentSummary.extend({
+ summary: z.string().nullable(),
+ declaredBy: personSchema.nullable(),
+ statusReport: z
+ .object({ id: z.number().int(), title: z.string(), status: z.string() })
+ .nullable(),
+ events: z.array(
+ z.object({
+ type: z.string(),
+ message: z.string().nullable(),
+ at: z.string(),
+ by: personSchema.nullable(),
+ }),
+ ),
+});
+
+export const getIncidentTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "get_incident",
+ description:
+ "Get one managed incident with its timeline (newest first, up to 50 events) and linked status report.",
+ scope: "read",
+ destructive: false,
+ feature: FEATURE,
+ inputSchema: GetIncidentInput,
+ outputSchema: GetIncidentOutput,
+ async run({ ctx, input }) {
+ const row = await getIncident({ ctx, input });
+ if (!row) throw new NotFoundError("incident", input.id);
+ const events = await listIncidentEvents({ ctx, input });
+ return {
+ id: row.id,
+ title: row.title,
+ severity: row.severity,
+ status: row.status,
+ closed: row.closedAt !== null,
+ commander: person(row.commander),
+ declaredAt: row.declaredAt.toISOString(),
+ startedAt: row.startedAt.toISOString(),
+ statusReportId: row.statusReportId,
+ slackChannelId: row.slackChannelId,
+ summary: row.summary,
+ declaredBy: person(row.declaredByUser),
+ statusReport: row.statusReport
+ ? {
+ id: row.statusReport.id,
+ title: row.statusReport.title,
+ status: row.statusReport.status,
+ }
+ : null,
+ events: events.slice(0, 50).map((e) => ({
+ type: e.type,
+ message: e.message,
+ at: e.createdAt.toISOString(),
+ by: person(e.createdByUser),
+ })),
+ };
+ },
+};
+
+const IncidentWriteOutput = z.object({
+ id: z.number().int(),
+ title: z.string(),
+ severity: z.enum(incidentSeverity),
+ status: z.enum(incidentStatus),
+});
+
+function writeOutput(row: {
+ id: number;
+ title: string;
+ severity: z.infer["severity"];
+ status: z.infer["status"];
+}) {
+ return {
+ id: row.id,
+ title: row.title,
+ severity: row.severity,
+ status: row.status,
+ };
+}
+
+const DeclareIncidentInput = z.object({
+ title: z.string().min(1).max(256).describe("Short internal title."),
+ severity: z
+ .enum(incidentSeverity)
+ .describe(
+ "critical: major outage or data loss; major: significant degradation; minor: limited impact.",
+ ),
+ summary: z.string().max(4000).optional().describe("What is happening."),
+ commanderId: z
+ .number()
+ .int()
+ .optional()
+ .describe(
+ "Member leading the response. Omit to make the requester commander.",
+ ),
+ startedAt: z.iso
+ .datetime()
+ .optional()
+ .describe("When the impact began, ISO 8601. Omit for now."),
+ statusReportId: z
+ .number()
+ .int()
+ .optional()
+ .describe("Existing status report to link, from list_status_reports."),
+});
+
+export const declareIncidentTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "declare_incident",
+ description:
+ "Declare a managed incident: the team's internal record of an outage, with a timeline and a commander. Internal only — nothing is published; use create_status_report for public communication.",
+ scope: "write",
+ destructive: true,
+ feature: FEATURE,
+ inputSchema: DeclareIncidentInput,
+ outputSchema: IncidentWriteOutput,
+ approval: {
+ summarize: (input) => ({
+ title: `Declare incident: ${input.title}`,
+ lines: [
+ { label: "Title", value: input.title },
+ { label: "Severity", value: input.severity },
+ ...optionalLines([
+ ["Summary", input.summary],
+ ["Started at", input.startedAt],
+ [
+ "Commander",
+ input.commanderId ? `#${input.commanderId}` : undefined,
+ ],
+ [
+ "Status report",
+ input.statusReportId ? `#${input.statusReportId}` : undefined,
+ ],
+ ]),
+ ],
+ }),
+ verb: "declared",
+ },
+ async run({ ctx, input }) {
+ const row = await declareIncident({
+ ctx,
+ input: {
+ title: input.title,
+ severity: input.severity,
+ summary: input.summary,
+ commanderId: input.commanderId ?? tryGetActorUserId(ctx.actor),
+ startedAt: input.startedAt ? new Date(input.startedAt) : undefined,
+ statusReportId: input.statusReportId,
+ },
+ });
+ return writeOutput(row);
+ },
+};
+
+const UpdateIncidentInput = z.object({
+ id: z.number().int().describe("Incident id, from list_incidents."),
+ title: z.string().min(1).max(256).optional(),
+ severity: z.enum(incidentSeverity).optional(),
+ summary: z.string().max(4000).optional(),
+ commanderId: z.number().int().optional().describe("New commander's user id."),
+ startedAt: z.iso.datetime().optional().describe("Corrected start, ISO 8601."),
+});
+
+export const updateIncidentTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "update_incident",
+ description:
+ "Change a managed incident's title, severity, summary, commander or start time. Internal only.",
+ scope: "write",
+ destructive: true,
+ feature: FEATURE,
+ inputSchema: UpdateIncidentInput,
+ outputSchema: IncidentWriteOutput,
+ approval: {
+ summarize: (input) => ({
+ title: `Update incident #${input.id}`,
+ lines: optionalLines([
+ ["Title", input.title],
+ ["Severity", input.severity],
+ ["Summary", input.summary],
+ ["Commander", input.commanderId ? `#${input.commanderId}` : undefined],
+ ["Started at", input.startedAt],
+ ]),
+ }),
+ verb: "updated",
+ },
+ async run({ ctx, input }) {
+ const row = await updateIncident({
+ ctx,
+ input: {
+ id: input.id,
+ title: input.title,
+ severity: input.severity,
+ summary: input.summary,
+ commanderId: input.commanderId,
+ startedAt: input.startedAt ? new Date(input.startedAt) : undefined,
+ },
+ });
+ return writeOutput(row);
+ },
+};
+
+const ResolveIncidentInput = z.object({
+ id: z.number().int().describe("Incident id, from list_incidents."),
+ note: z.string().max(10_000).optional().describe("What fixed it."),
+});
+
+export const resolveIncidentTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "resolve_incident",
+ description:
+ "Mark a managed incident resolved. Does not touch its status report: resolve that separately with resolve_status_report if it is still open.",
+ scope: "write",
+ destructive: true,
+ feature: FEATURE,
+ inputSchema: ResolveIncidentInput,
+ outputSchema: IncidentWriteOutput,
+ approval: {
+ summarize: (input) => ({
+ title: `Resolve incident #${input.id}`,
+ lines: optionalLines([["Note", input.note]]),
+ }),
+ verb: "resolved",
+ },
+ async run({ ctx, input }) {
+ const row = await setIncidentStatus({
+ ctx,
+ input: {
+ id: input.id,
+ status: "resolved",
+ note: input.note || undefined,
+ },
+ });
+ return writeOutput(row);
+ },
+};
+
+const AddIncidentNoteInput = z.object({
+ id: z.number().int().describe("Incident id, from list_incidents."),
+ message: z.string().min(1).max(10_000).describe("The note, markdown."),
+});
+
+const AddIncidentNoteOutput = z.object({
+ incidentId: z.number().int(),
+ eventId: z.number().int(),
+});
+
+export const addIncidentNoteTool: AgentTool<
+ z.infer,
+ z.infer
+> = {
+ name: "add_incident_note",
+ description:
+ "Append a note to a managed incident's timeline. Internal and append-only.",
+ scope: "write",
+ destructive: false,
+ feature: FEATURE,
+ inputSchema: AddIncidentNoteInput,
+ outputSchema: AddIncidentNoteOutput,
+ async run({ ctx, input }) {
+ const event = await addIncidentNote({ ctx, input });
+ return { incidentId: input.id, eventId: event.id };
+ },
+};
+
+function optionalLines(
+ entries: Array<[string, string | undefined]>,
+): SummaryLine[] {
+ return entries.flatMap(([label, value]) => (value ? [{ label, value }] : []));
+}
diff --git a/packages/services/src/agent-tools/index.ts b/packages/services/src/agent-tools/index.ts
index 28edff49..ec49ddd9 100644
--- a/packages/services/src/agent-tools/index.ts
+++ b/packages/services/src/agent-tools/index.ts
@@ -1,8 +1,18 @@
import type { z } from "zod";
+import { isFeatureEnabled } from "../features";
+import type { Workspace } from "../types";
import { getAuditLogTool, listAuditLogsTool } from "./audit";
import { getContentPageTool, searchContentTool } from "./content";
import { getDocPageTool, searchDocsTool } from "./docs";
+import {
+ addIncidentNoteTool,
+ declareIncidentTool,
+ getIncidentTool,
+ listIncidentsTool,
+ resolveIncidentTool,
+ updateIncidentTool,
+} from "./incident";
import { createMaintenanceTool, listMaintenancesTool } from "./maintenance";
import {
getMonitorStatusTool,
@@ -28,6 +38,14 @@ import type { AnyAgentTool } from "./types";
export { getAuditLogTool, listAuditLogsTool } from "./audit";
export { getContentPageTool, searchContentTool } from "./content";
export { getDocPageTool, searchDocsTool } from "./docs";
+export {
+ addIncidentNoteTool,
+ declareIncidentTool,
+ getIncidentTool,
+ listIncidentsTool,
+ resolveIncidentTool,
+ updateIncidentTool,
+} from "./incident";
export { createMaintenanceTool, listMaintenancesTool } from "./maintenance";
export {
getMonitorStatusTool,
@@ -82,6 +100,12 @@ export const agentTools = {
add_status_report_update: addStatusReportUpdateTool,
update_status_report: updateStatusReportTool,
resolve_status_report: resolveStatusReportTool,
+ list_incidents: listIncidentsTool,
+ get_incident: getIncidentTool,
+ declare_incident: declareIncidentTool,
+ update_incident: updateIncidentTool,
+ resolve_incident: resolveIncidentTool,
+ add_incident_note: addIncidentNoteTool,
list_maintenances: listMaintenancesTool,
create_maintenance: createMaintenanceTool,
list_monitors: listMonitorsTool,
@@ -116,6 +140,14 @@ for (const tool of Object.values(agentTools) as AnyAgentTool[]) {
}
}
+/** Whether a tool is offered in this workspace (feature-gated tools are hidden). */
+export function isAgentToolAvailable(
+ tool: AnyAgentTool,
+ workspace: Pick,
+): boolean {
+ return !tool.feature || isFeatureEnabled(workspace, tool.feature);
+}
+
/**
* Iteration view used by adapters that don't care about the literal key.
* Equivalent to `Object.values(agentTools)`.
diff --git a/packages/services/src/agent-tools/prompt.ts b/packages/services/src/agent-tools/prompt.ts
index 8c996015..742c5c47 100644
--- a/packages/services/src/agent-tools/prompt.ts
+++ b/packages/services/src/agent-tools/prompt.ts
@@ -16,6 +16,8 @@ export type AgentSystemPromptOptions = {
* false the model passes `notify: false` and skips the yes/no prompt.
*/
canNotifySubscribers: boolean;
+ /** Whether managed-incident tools are offered in this workspace. */
+ incidentManagement?: boolean;
};
export function buildAgentSystemPrompt(opts: AgentSystemPromptOptions): string {
@@ -39,6 +41,18 @@ Exception: after get_doc_page or get_content_page, DO synthesize an answer from
const preamble = opts.preamble ? `${opts.preamble}\n\n` : "";
+ const incidentSection = opts.incidentManagement
+ ? `
+
+Managed incidents (internal):
+- Three different things are called "incident". A managed incident (list_incidents, get_incident, declare_incident, update_incident, resolve_incident, add_incident_note) is the team's INTERNAL record: severity, commander, timeline. A status report is PUBLIC communication on a status page. Monitor downtime (activeIncidentCount on monitors) is detected automatically.
+- "declare an incident", "open an incident", "we have a SEV" → declare_incident. It publishes nothing. Offer to create a status report afterwards and pass incidentId to create_status_report to link them.
+- Before referencing a managed incident: call list_incidents. Never guess its id.
+- "note that…", "add to the timeline", "log that…" → add_incident_note (internal, runs without confirmation).
+- "the incident is fixed/resolved" with a managed incident in play → resolve_incident; if its linked status report is still open, ask whether to resolve that too (resolve_status_report).
+- severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. Ask when unclear.`
+ : "";
+
// Workspaces without subscriber notify get a different rubric — asking
// is wasted friction when the field is a server-side no-op anyway.
const notifyStep = opts.canNotifySubscribers
@@ -86,7 +100,7 @@ Lifecycle:
- "provide an update", "we found the cause", "still investigating" → add_status_report_update.
- "rename the report", "add a component" → update_status_report (metadata only — does not notify).
- "it's fixed", "incident is resolved" → resolve_status_report (publishes a final update).
-- Status progression hint: investigating → identified → monitoring → resolved.
+- Status progression hint: investigating → identified → monitoring → resolved.${incidentSection}
Inferring status from conversation:
- "we have an incident" → investigating
diff --git a/packages/services/src/agent-tools/status-report.ts b/packages/services/src/agent-tools/status-report.ts
index 453fcaa2..10b4821c 100644
--- a/packages/services/src/agent-tools/status-report.ts
+++ b/packages/services/src/agent-tools/status-report.ts
@@ -205,6 +205,13 @@ const CreateStatusReportInputShape = z.object({
.datetime()
.optional()
.describe("Override the initial update's date. Defaults to now."),
+ incidentId: z
+ .number()
+ .int()
+ .optional()
+ .describe(
+ "Managed incident this report communicates, from list_incidents. Links them.",
+ ),
notify: z
.boolean()
.describe(
@@ -274,6 +281,9 @@ export const createStatusReportTool: AgentTool<
},
]
: []),
+ ...(input.incidentId
+ ? [{ label: "Incident", value: `#${input.incidentId}` }]
+ : []),
{ label: "Message", value: input.message },
],
}),
@@ -290,6 +300,7 @@ export const createStatusReportTool: AgentTool<
pageComponentIds: input.pageComponentIds ?? [],
componentImpacts: input.componentImpacts,
date: input.date ? new Date(input.date) : new Date(),
+ incidentId: input.incidentId,
},
});
let notified = false;
diff --git a/packages/services/src/agent-tools/types.ts b/packages/services/src/agent-tools/types.ts
index 48d29cdf..2d2b4447 100644
--- a/packages/services/src/agent-tools/types.ts
+++ b/packages/services/src/agent-tools/types.ts
@@ -2,6 +2,7 @@ import type { Scope } from "@openstatus/db/src/schema";
import type { ZodType, z } from "zod";
import type { ServiceContext } from "../context";
+import type { Feature } from "../features";
/**
* Framework-agnostic tool descriptor shared by every adapter (MCP, AI SDK, Slack).
@@ -20,6 +21,9 @@ export type AgentTool = {
outputSchema: ZodType;
run(args: { ctx: ServiceContext; input: TInput }): Promise;
+ /** Hidden from every surface unless this feature is on for the workspace. */
+ feature?: Feature;
+
/**
* Optional HITL metadata consumed by surfaces that orchestrate approval
* outside `run()` (today: Slack). MCP relies on `destructiveHint`; the