diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md
index 58c11c02..097643e8 100644
--- a/apps/dashboard/README.md
+++ b/apps/dashboard/README.md
@@ -63,7 +63,7 @@ pnpm -w dev:dashboard
Turbo runs the dashboard (`apps/dashboard`) and `@openstatus/db` together.
-6. Open [http://localhost:3000](http://localhost:3000)
+6. Open [http://localhost:3001](http://localhost:3001)
## Logging in
@@ -73,7 +73,7 @@ In `NODE_ENV=development` or `SELF_HOST=true`, `src/lib/auth/providers.ts` confi
To log in:
-1. Open [http://localhost:3000/login](http://localhost:3000/login)
+1. Open [http://localhost:3001/login](http://localhost:3001/login)
2. Enter `ping@openstatus.dev` (the seeded user, bound to workspace 1) in the magic-link form
3. Watch the dashboard terminal — the magic link is logged there. Open it in your browser.
diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts
index ce4e94a6..a419cbe4 100644
--- a/apps/dashboard/next-env.d.ts
+++ b/apps/dashboard/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 0b2fb9b7..b640d621 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
- "dev": "next dev --turbopack",
+ "dev": "PORT=3001 next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx
index 28b7e2aa..a8d82e2d 100644
--- a/apps/dashboard/src/components/data-table/billing/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx
@@ -37,7 +37,7 @@ import { cn } from "@/lib/utils";
const BASE_URL =
process.env.NODE_ENV === "production"
? "https://app.openstatus.dev"
- : "http://localhost:3000";
+ : "http://localhost:3001";
function getPeriodSuffix(interval: BillingInterval) {
return interval === "yearly" ? "/yr." : "/mo.";
diff --git a/apps/dashboard/src/lib/trpc/shared.ts b/apps/dashboard/src/lib/trpc/shared.ts
index 01f8f361..501d3afa 100644
--- a/apps/dashboard/src/lib/trpc/shared.ts
+++ b/apps/dashboard/src/lib/trpc/shared.ts
@@ -51,7 +51,8 @@ const getBaseUrl = () => {
if (typeof window !== "undefined") return "";
// Note: dashboard has its own tRPC API routes
if (process.env.VERCEL_URL) return "https://app.openstatus.dev"; // Vercel
- return "http://localhost:3000"; // Local dev and Docker (internal calls)
+ // Dev runs on 3001 (`PORT` in the dev script), Docker on 3000.
+ return `http://localhost:${process.env.PORT || 3000}`; // Local dev and Docker (internal calls)
};
// The whole tRPC surface is served from a single Node.js endpoint — there is
diff --git a/apps/server/.gitignore b/apps/server/.gitignore
index c1fbf070..12a21cd5 100644
--- a/apps/server/.gitignore
+++ b/apps/server/.gitignore
@@ -3,3 +3,4 @@ app
src/_serve.bundle.mjs
.deno_compile_bundle_*.mjs
app.tmp-*
+slack-manifest.dev.json
diff --git a/apps/server/package.json b/apps/server/package.json
index da2e3a93..b4f6f30e 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -10,6 +10,8 @@
"start": "NODE_ENV=production deno run -A --sloppy-imports src/serve.ts",
"test": "deno test --parallel -A --no-check --sloppy-imports --env-file=.env.test --import-map=test.importmap.json",
"check": "deno check --sloppy-imports src/serve.ts",
+ "slack:manifest:prod": "cat slack-manifest.json",
+ "slack:manifest:dev": "deno run --env-file=.env --allow-env --allow-write=slack-manifest.dev.json scripts/slack-manifest-dev.ts",
"eval:mcp": "deno run -A --sloppy-imports src/routes/mcp/evals/run.ts",
"openapi:json": "deno run -A scripts/build-openapi-json.ts && pnpm exec oxfmt static/openapi.json static/openapi-yaml.ts",
"openapi:v1": "deno run -A --no-check --sloppy-imports --env-file=.env.test --import-map=test.importmap.json scripts/build-openapi-v1-json.ts && pnpm exec oxfmt static/openapi-v1.json"
diff --git a/apps/server/scripts/slack-manifest-dev.ts b/apps/server/scripts/slack-manifest-dev.ts
new file mode 100644
index 00000000..89b336ec
--- /dev/null
+++ b/apps/server/scripts/slack-manifest-dev.ts
@@ -0,0 +1,32 @@
+// Writes slack-manifest.dev.json (gitignored) for the local `openstatus-dev`
+// app: the production manifest renamed, with every api.openstatus.dev URL
+// pointed at your tunnel. The tunnel origin comes from SLACK_REDIRECT_URI in
+// .env, so the manifest and the OAuth callback the server sends can't disagree.
+// Also prints it, so it can be piped straight to the clipboard.
+//
+// pnpm slack:manifest:dev | pbcopy # then App Manifest → JSON → paste
+import manifest from "../slack-manifest.json" with { type: "json" };
+
+const PROD_ORIGIN = "https://api.openstatus.dev";
+const DEV_NAME = "openstatus-dev";
+
+const redirectUri = Deno.env.get("SLACK_REDIRECT_URI");
+if (!redirectUri) {
+ console.error(
+ "SLACK_REDIRECT_URI is not set — add https:///slack/oauth/callback to apps/server/.env",
+ );
+ Deno.exit(1);
+}
+const devOrigin = new URL(redirectUri).origin;
+
+const dev = structuredClone(manifest);
+dev.display_information.name = DEV_NAME;
+dev.features.bot_user.display_name = DEV_NAME;
+dev.oauth_config.redirect_urls = [redirectUri];
+
+const output = `${JSON.stringify(dev, null, 2).replaceAll(PROD_ORIGIN, devOrigin)}\n`;
+await Deno.writeTextFile(
+ new URL("../slack-manifest.dev.json", import.meta.url),
+ output,
+);
+console.log(output);
diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json
index b6574141..49dd6c72 100644
--- a/apps/server/slack-manifest.json
+++ b/apps/server/slack-manifest.json
@@ -1,15 +1,36 @@
{
"display_information": {
"name": "openstatus",
- "description": "Manage incidents and status pages directly from Slack.",
+ "description": "The incident communication agent for your status pages.",
"background_color": "#000000",
- "long_description": "openstatus brings incident management into your team's Slack workspace. Mention the bot in any channel to create, update, and resolve status reports — no context switching required.\n\n How it works:\n :one: Describe the issue by mentioning @openstatus in any channel or thread\n :two: The assistant reads the conversation, queries your status pages, and drafts a status update\n :three: Review the proposed action and click Approve, Approve & Notify, or Cancel\n\n What you can do:\n :white_check_mark: Create status reports from natural conversation\n :white_check_mark: Post progress updates as incidents evolve\n :white_check_mark: Resolve incidents when the issue is fixed\n :white_check_mark: Notify your status page subscribers with one click\n :white_check_mark: Edit report titles and affected components\n\n The assistant understands context — tag it in an ongoing thread and it will synthesize the discussion into a clear, public-facing status update. It infers incident status automatically: \"we found the root cause\" becomes Identified, \"it's fixed\" becomes Resolved.\n\n AI disclaimer: openstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may sometimes be inaccurate, incomplete, or misleading. Always review every draft before approving it — nothing is posted to your status page until you explicitly confirm. Only the person who triggered the action can approve it, and you can edit or cancel any draft."
+ "long_description": "openstatus is an agent for incident communication. Open it from the Slack top bar, or mention @openstatus in any channel, and it turns the conversation into status page updates — no context switching required.\n\n How it works:\n :one: Open openstatus in the agent pane and describe the issue, or mention @openstatus in an incident channel or thread\n :two: The agent reads the conversation, queries your status pages, and drafts a status update\n :three: Review the proposed action and click Approve, Approve & Notify, or Cancel\n\n What you can do:\n :white_check_mark: Create status reports from natural conversation\n :white_check_mark: Post progress updates as incidents evolve\n :white_check_mark: Resolve incidents when the issue is fixed\n :white_check_mark: Schedule maintenance windows in plain language\n :white_check_mark: Notify your status page subscribers with one click\n\n The agent understands context — tag it in an ongoing thread and it will synthesize the discussion into a clear, public-facing status update. It infers incident status automatically: \"we found the root cause\" becomes Identified, \"it's fixed\" becomes Resolved.\n\n AI disclaimer: openstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may sometimes be inaccurate, incomplete, or misleading. Always review every draft before approving it — nothing is posted to your status page until you explicitly confirm. Only the person who triggered the action can approve it, and you can edit or cancel any draft."
},
"features": {
+ "assistant_view": {
+ "assistant_description": "Drafts, updates and resolves status reports and maintenance windows. Nothing is published until you approve it.",
+ "suggested_prompts": [
+ {
+ "title": "Create a status report",
+ "message": "We're seeing elevated errors on the API — create a status report."
+ },
+ {
+ "title": "Open status reports",
+ "message": "Which status reports are currently open?"
+ },
+ {
+ "title": "Schedule maintenance",
+ "message": "Schedule a maintenance window next Tuesday from 2-3 PM UTC."
+ },
+ {
+ "title": "Upcoming maintenance",
+ "message": "What maintenance windows are coming up?"
+ }
+ ]
+ },
"app_home": {
"home_tab_enabled": true,
- "messages_tab_enabled": false,
- "messages_tab_read_only_enabled": true
+ "messages_tab_enabled": true,
+ "messages_tab_read_only_enabled": false
},
"bot_user": {
"display_name": "openstatus",
@@ -32,13 +53,15 @@
"user_optional": ["groups:write"],
"bot": [
"app_mentions:read",
+ "assistant:write",
"channels:history",
"channels:join",
"chat:write",
"commands",
"groups:history",
"groups:read",
- "groups:write"
+ "groups:write",
+ "im:history"
]
},
"pkce_enabled": false
@@ -49,8 +72,10 @@
"bot_events": [
"app_home_opened",
"app_mention",
+ "assistant_thread_started",
"message.channels",
- "message.groups"
+ "message.groups",
+ "message.im"
]
},
"interactivity": {
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 424e115b..b0dae007 100644
--- a/apps/server/src/libs/test/doubles/page-urls.mock.ts
+++ b/apps/server/src/libs/test/doubles/page-urls.mock.ts
@@ -23,3 +23,12 @@ export const getComponentNames = (
ids: number[],
): Promise