+
{stories.map((story) => {
return (
diff --git a/apps/webapp/app/routes/webhooks.slack.ts b/apps/webapp/app/routes/webhooks.slack.ts
new file mode 100644
index 00000000000..5529e45759d
--- /dev/null
+++ b/apps/webapp/app/routes/webhooks.slack.ts
@@ -0,0 +1,140 @@
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { json } from "@remix-run/node";
+import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+
+/**
+ * POST /webhooks/slack
+ * Receives Slack messages and routes them to the correct OpenClaw agent
+ */
+export const action = async ({ request }: ActionFunctionArgs) => {
+ if (request.method !== "POST") {
+ return json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ try {
+ const event = await request.json() as any;
+
+ // Handle Slack URL verification
+ if (event.type === "url_verification") {
+ return json({ challenge: event.challenge });
+ }
+
+ // Handle message events
+ if (event.type === "event_callback" && event.event.type === "message") {
+ const slackEvent = event.event;
+ const workspaceId = event.team_id;
+ const channel = slackEvent.channel;
+ const text = slackEvent.text;
+ const userId = slackEvent.user;
+
+ logger.info("Received Slack message", {
+ workspaceId,
+ channel,
+ userId,
+ text: text?.substring(0, 100),
+ });
+
+ // Find the agent for this workspace
+ const agent = await prisma.agentConfig.findFirst({
+ where: {
+ slackWorkspaceId: workspaceId,
+ messagingPlatform: "slack",
+ status: "healthy",
+ },
+ });
+
+ if (!agent) {
+ logger.warn("No agent found for workspace", { workspaceId });
+ return json({ ok: true }); // Don't error, just ignore
+ }
+
+ if (!agent.containerPort) {
+ logger.warn("Agent has no container port", { agentId: agent.id });
+ return json({ ok: true });
+ }
+
+ // Route message to OpenClaw container (on VPS)
+ const containerUrl = `http://178.128.150.129:${agent.containerPort}`;
+
+ try {
+ const containerResponse = await fetch(`${containerUrl}/api/message`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ text,
+ userId,
+ channel,
+ metadata: {
+ slackUserId: userId,
+ slackChannel: channel,
+ timestamp: new Date().toISOString(),
+ },
+ }),
+ });
+
+ const containerData = await containerResponse.json();
+ const agentResponse = containerData?.response || "I couldn't process that";
+
+ // Log execution
+ await prisma.agentExecution.create({
+ data: {
+ agentId: agent.id,
+ message: text,
+ response: agentResponse,
+ executionTimeMs: 0, // TODO: Measure actual execution time
+ inputTokens: containerData?.inputTokens,
+ outputTokens: containerData?.outputTokens,
+ },
+ });
+
+ // Send response back to Slack
+ if (agent.slackWebhookToken) {
+ await fetch(`https://hooks.slack.com/services/${agent.slackWebhookToken}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ channel,
+ text: agentResponse,
+ reply_broadcast: false,
+ thread_ts: slackEvent.thread_ts || slackEvent.ts,
+ }),
+ });
+ }
+
+ logger.info("Message processed successfully", {
+ agentId: agent.id,
+ responseLength: agentResponse.length,
+ });
+ } catch (containerError) {
+ logger.error("Failed to route message to container", {
+ agentId: agent.id,
+ containerPort: agent.containerPort,
+ error: containerError,
+ });
+
+ // Mark agent as unhealthy
+ await prisma.agentConfig.update({
+ where: { id: agent.id },
+ data: { status: "unhealthy" },
+ });
+
+ // Log health check failure
+ await prisma.agentHealthCheck.create({
+ data: {
+ agentId: agent.id,
+ isHealthy: false,
+ errorMessage: containerError instanceof Error ? containerError.message : "Unknown error",
+ },
+ });
+
+ return json({ ok: true }); // Don't fail the webhook, just mark agent unhealthy
+ }
+ }
+
+ return json({ ok: true });
+ } catch (error) {
+ logger.error("Webhook processing error", { error });
+ return json({ ok: true }, { status: 200 }); // Always return 200 to Slack
+ }
+};
diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js
index 130c1591962..af410d1552a 100644
--- a/apps/webapp/remix.config.js
+++ b/apps/webapp/remix.config.js
@@ -1,7 +1,7 @@
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
dev: {
- port: 8002,
+ port: 8003,
},
tailwind: true,
cacheDirectory: "./node_modules/.cache/remix",
diff --git a/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql
new file mode 100644
index 00000000000..61c239b1b77
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql
@@ -0,0 +1,121 @@
+-- DropIndex
+DROP INDEX "public"."SecretStore_key_idx";
+
+-- DropIndex
+DROP INDEX "public"."TaskRun_runtimeEnvironmentId_createdAt_idx";
+
+-- DropIndex
+DROP INDEX "public"."TaskRun_runtimeEnvironmentId_id_idx";
+
+-- AlterTable
+ALTER TABLE "public"."FeatureFlag" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "public"."IntegrationDeployment" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
+
+-- AlterTable
+ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
+
+-- AlterTable
+ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
+
+-- AlterTable
+ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
+
+-- AlterTable
+ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "public"."_completedWaitpoints_AB_unique";
+
+-- CreateTable
+CREATE TABLE "public"."AgentConfig" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "model" TEXT NOT NULL,
+ "messagingPlatform" TEXT NOT NULL,
+ "tools" JSONB NOT NULL,
+ "containerName" TEXT,
+ "containerPort" INTEGER,
+ "slackWorkspaceId" TEXT,
+ "slackWebhookToken" TEXT,
+ "apiKeys" JSONB,
+ "status" TEXT NOT NULL DEFAULT 'provisioning',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AgentConfig_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "public"."AgentExecution" (
+ "id" TEXT NOT NULL,
+ "agentId" TEXT NOT NULL,
+ "message" TEXT NOT NULL,
+ "response" TEXT NOT NULL,
+ "toolsUsed" JSONB,
+ "executionTimeMs" INTEGER NOT NULL,
+ "inputTokens" INTEGER,
+ "outputTokens" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentExecution_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "public"."AgentHealthCheck" (
+ "id" TEXT NOT NULL,
+ "agentId" TEXT NOT NULL,
+ "responseTimeMs" INTEGER,
+ "isHealthy" BOOLEAN NOT NULL,
+ "errorMessage" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentHealthCheck_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "AgentConfig_userId_createdAt_idx" ON "public"."AgentConfig"("userId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "AgentConfig_slackWorkspaceId_idx" ON "public"."AgentConfig"("slackWorkspaceId");
+
+-- CreateIndex
+CREATE INDEX "AgentExecution_agentId_createdAt_idx" ON "public"."AgentExecution"("agentId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "AgentHealthCheck_agentId_createdAt_idx" ON "public"."AgentHealthCheck"("agentId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
+
+-- CreateIndex
+CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
+
+-- CreateIndex
+CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
+
+-- AddForeignKey
+ALTER TABLE "public"."AgentConfig" ADD CONSTRAINT "AgentConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."AgentExecution" ADD CONSTRAINT "AgentExecution_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."AgentHealthCheck" ADD CONSTRAINT "AgentHealthCheck_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index 2aeb27e3038..ea7435579b2 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -79,6 +79,7 @@ model User {
metricsDashboards MetricsDashboard[]
platformNotifications PlatformNotification[]
platformNotificationInteractions PlatformNotificationInteraction[]
+ agentConfigs AgentConfig[]
}
model MfaBackupCode {
@@ -3180,3 +3181,104 @@ model OrganizationDataStore {
@@index([kind])
}
+
+// ====================================================
+// OpenClaw Agent Models
+// ====================================================
+
+/// OpenClaw Agent Configuration
+model AgentConfig {
+ id String @id @default(cuid())
+
+ /// Owner of this agent
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String
+
+ /// Agent display name
+ name String
+
+ /// Model selected (e.g., claude-3.5-sonnet)
+ model String
+
+ /// Messaging platform (slack, discord, telegram)
+ messagingPlatform String
+
+ /// Tools/skills enabled (JSON array)
+ tools Json
+
+ /// Container name on VPS
+ containerName String?
+
+ /// Container port (e.g., 8001, 8002)
+ containerPort Int?
+
+ /// Slack workspace ID for routing
+ slackWorkspaceId String?
+
+ /// Webhook token for Slack
+ slackWebhookToken String?
+
+ /// User's API keys (encrypted)
+ apiKeys Json?
+
+ /// Provisioning state
+ status String @default("provisioning")
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ executions AgentExecution[]
+ healthChecks AgentHealthCheck[]
+
+ @@index([userId, createdAt(sort: Desc)])
+ @@index([slackWorkspaceId])
+}
+
+/// Execution history
+model AgentExecution {
+ id String @id @default(cuid())
+
+ agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade)
+ agentId String
+
+ /// Input message
+ message String
+
+ /// Output response
+ response String
+
+ /// Tools used in this execution
+ toolsUsed Json?
+
+ /// Execution time in ms
+ executionTimeMs Int
+
+ /// Token usage
+ inputTokens Int?
+ outputTokens Int?
+
+ createdAt DateTime @default(now())
+
+ @@index([agentId, createdAt(sort: Desc)])
+}
+
+/// Health check history
+model AgentHealthCheck {
+ id String @id @default(cuid())
+
+ agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade)
+ agentId String
+
+ /// Response time in ms
+ responseTimeMs Int?
+
+ /// Healthy or not
+ isHealthy Boolean
+
+ /// Error message if unhealthy
+ errorMessage String?
+
+ createdAt DateTime @default(now())
+
+ @@index([agentId, createdAt(sort: Desc)])
+}
diff --git a/internal-packages/emails/emails/components/Footer.tsx b/internal-packages/emails/emails/components/Footer.tsx
index 00f128c8bea..7c722e70694 100644
--- a/internal-packages/emails/emails/components/Footer.tsx
+++ b/internal-packages/emails/emails/components/Footer.tsx
@@ -8,7 +8,7 @@ export function Footer() {
©Trigger.dev, 1111B S Governors Ave STE 6433, Dover, DE 19904 |{" "}
-
+
Trigger.dev