Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
204f250
spike(agent): resume a parked run from an outside event
ripgrim Aug 26, 2026
15abf2f
feat(agent): route a Slack event to the run that owns the channel
ripgrim Aug 26, 2026
c5ad91f
feat(agent): take Slack events in, and route them to a run
ripgrim Aug 26, 2026
abe13b9
feat(api): accept Slack events, verify them, and hand them to the agent
ripgrim Aug 26, 2026
075dccc
feat(agent): drain the Slack inbox into a parked run
ripgrim Aug 28, 2026
0e7b727
feat(agent): let a run open its own Slack channel and watch it
ripgrim Aug 28, 2026
ee6ac0d
feat(agent): invite people to the channel a run opened
ripgrim Aug 28, 2026
c810748
feat(dev): give the Slack tunnel a hostname that survives a restart
ripgrim Aug 28, 2026
c04499d
feat(agent): teach the agent how to onboard a customer into Slack
ripgrim Aug 28, 2026
acce304
feat(agent): let a deployed run open a Slack channel and invite the c…
ripgrim Aug 28, 2026
b467676
feat(agent): add the deal owner to the channel a run opens
ripgrim Aug 30, 2026
0ddfbc5
fix(auth): request app_mentions:read, without which mentions never ar…
ripgrim Aug 30, 2026
8387329
fix(agent): keep Slack's invite so a run can prove it was sent
ripgrim Aug 30, 2026
a12b23a
fix(agent): store parsed action results as Prisma JSON
ripgrim Aug 30, 2026
9c3a6c5
fix(agent): name Slack request bodies so lint:slop can pass
ripgrim Aug 30, 2026
d1549da
fix(validation): sort SlackEvent imports so biome can lint
ripgrim Aug 30, 2026
70da103
fix(validation): export agentActionResult under one name
ripgrim Aug 30, 2026
42b85a0
fix(agent): ask Slack for the Connect invite url
ripgrim Aug 30, 2026
966c958
fix: close Slack resume holes so a drain cannot lose or double-handle…
ripgrim Aug 30, 2026
bc1a4b3
fix(api): declare Express so the Slack raw-body cap can load
ripgrim Aug 30, 2026
40d08e8
copy: apply Cal's CRM-13 Slack strings
ripgrim Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ GOOGLE_CLIENT_SECRET=""
# SLACK_CLIENT_ID=""
# SLACK_CLIENT_SECRET=""

# Optional, and required only for inbound Slack. It is the Signing Secret on the
# Slack app's Basic Information page, not a token. Without it the events
# endpoint refuses every request, which is the safe way round: that endpoint is
# a public POST, so the signature is the only thing between a stranger and
# resuming somebody's agent run.
#
# Point the Slack app's Event Subscriptions request URL at
# API_URL + /webhooks/slack/events
# SLACK_SIGNING_SECRET=""

# Optional, and for local development only. The hostname `bun run tunnel:slack`
# gives your machine, on a domain in your own Cloudflare account. A named tunnel
# keeps the same hostname across restarts, so Slack's request URL is set once
# and never again; the quick `cloudflared --url` tunnel invents a new hostname
# every time and Slack silently stops delivering.
#
# Nothing reads this at runtime. The script reads it, and Slack remembers it.
# SLACK_TUNNEL_HOSTNAME="crm-dev.example.com"

# Which Entra tenant may sign in. "common" (the default) accepts any work,
# school or personal Microsoft account and leans on ALLOWED_SIGN_IN to decide
# who actually gets in; your own tenant's GUID refuses everyone else at
Expand Down
2 changes: 2 additions & 0 deletions apps/agent/agent/channels/crm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { DISPATCH } from "../lib/dispatch-config";
import { settle } from "../lib/enrichment";
import { finishRun, runResultOf } from "../lib/run-runtime";
import { attribute } from "../lib/session-purpose";
import { drainSlackEvents } from "../lib/slack-events";
import { createSlackChannel } from "../lib/slack-membership";
import { reconcileStaleTasks } from "../lib/stale-tasks";
import { completeTask, taskSubject } from "../lib/tasks";
Expand Down Expand Up @@ -146,6 +147,7 @@ export default defineChannel({
}),
);
await drainAgentRuns(send);
await drainSlackEvents(send);
})(),
);

Expand Down
19 changes: 13 additions & 6 deletions apps/agent/agent/lib/agent-actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
AGENT_ACTION_TYPES,
type AgentActionType,
SLACK_WORKSPACE_RESOURCE_ID,
} from "@crm/validation/agent-manifest";

export const AGENT_ACTION_EXECUTORS = {
[AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity",
[AGENT_ACTION_TYPES.RUN_SUMMARY]: "finish_run",
[AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message",
[AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: "open_slack_channel",

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a run declares slack.channel.open or slack.channel.invite but never calls it, the required-action failure is persisted as a CRM action. Classify all Slack action types as provider: "slack" in requiredActionFailure so the action ledger retains the correct provider.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/agent-actions.ts, line 11:

<comment>When a run declares `slack.channel.open` or `slack.channel.invite` but never calls it, the required-action failure is persisted as a CRM action. Classify all Slack action types as `provider: "slack"` in `requiredActionFailure` so the action ledger retains the correct provider.</comment>

<file context>
@@ -1,12 +1,15 @@
 	[AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity",
 	[AGENT_ACTION_TYPES.RUN_SUMMARY]: "finish_run",
 	[AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message",
+	[AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: "open_slack_channel",
+	[AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: "invite_to_slack_channel",
 } as const satisfies Record<AgentActionType, string>;
</file context>
Fix with cubic

[AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: "invite_to_slack_channel",
} as const satisfies Record<AgentActionType, string>;

export function isAgentActionType(value: string): value is AgentActionType {
Expand All @@ -22,15 +25,19 @@ export type AgentActionDependency = {
readonly fix: string;
};

const SLACK_DEPENDENCY = {
id: "slack",
label: "Slack",
resourceId: SLACK_WORKSPACE_RESOURCE_ID,
fix: "Connect Slack in Settings → Connections.",
} as const satisfies AgentActionDependency;

export const AGENT_ACTION_DEPENDENCIES = {
[AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: null,
[AGENT_ACTION_TYPES.RUN_SUMMARY]: null,
[AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: {
id: "slack",
label: "Slack",
resourceId: "slack:workspace",
fix: "Connect Slack in Settings → Connections.",
},
[AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: SLACK_DEPENDENCY,
[AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: SLACK_DEPENDENCY,
[AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: SLACK_DEPENDENCY,
} as const satisfies Record<AgentActionType, AgentActionDependency | null>;

export function actionDependency(
Expand Down
25 changes: 2 additions & 23 deletions apps/agent/agent/lib/builder-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { readAgentModel } from "@crm/db/settings";
import { WORKSPACE_ID } from "@crm/db/workspace";
import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest";
import { z } from "zod";
import type { DraftAction } from "../subagents/agent_builder/lib/draft-input";
import { actionDependency } from "./agent-actions";
import { requestStaleSlackInventorySync } from "./slack-people";

Expand Down Expand Up @@ -43,29 +44,7 @@ export type DraftTrigger = {
intervalMinutes?: number | null;
};

export type DraftAction =
| {
type: typeof AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE;
provider: "crm";
summary: string;
activityTypes: ("NOTE" | "TASK")[];
}
| {
type: typeof AGENT_ACTION_TYPES.RUN_SUMMARY;
provider: "crm";
summary: string;
}
| {
type: typeof AGENT_ACTION_TYPES.SLACK_MESSAGE_POST;
provider: "slack";
summary: string;
destination: {
kind: "channel" | "user";
resolution: "chosen";
id: string;
label: string;
};
};
export type { DraftAction };

export type DraftAgentInput = {
name: string;
Expand Down
130 changes: 130 additions & 0 deletions apps/agent/agent/lib/run-resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { db } from "@crm/db";
import type { SendFn } from "eve/channels";
import { APP_AUTH } from "./app-auth";
import { runToken } from "./custom-agent-dispatch";

const LIVE_STATUSES = ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] as const;

export type ResumeOutcome =
| { kind: "resumed"; runId: string; sessionId: string }
| { kind: "ignored"; runId: string; reason: string };

export type ResumeInput = {
runId: string;
message: string;
source: string;
attributes?: Readonly<Record<string, string>>;
};

export async function resumeAgentRun(
input: ResumeInput,
send: SendFn,
): Promise<ResumeOutcome> {
const { runId, message, source } = input;

const run = await db.agentRun.findUnique({
where: { id: runId },
select: {
id: true,
status: true,
sessionId: true,
agentId: true,
versionId: true,
agent: { select: { status: true, name: true } },
},
});

if (!run) return { kind: "ignored", runId, reason: "no such run" };

if (!(LIVE_STATUSES as readonly string[]).includes(run.status)) {
return {
kind: "ignored",
runId,
reason: `the run is ${run.status.toLowerCase()}`,
};
}

if (run.agent.status !== "LIVE") {
return {
kind: "ignored",
runId,
reason: `the agent is ${run.agent.status.toLowerCase()}`,
};
}

if (!run.sessionId) {
return {
kind: "ignored",
runId,
reason: "the run has not started a session yet",
};
}

try {
const session = await send(message, {
auth: {
authenticator: APP_AUTH.authenticator,
principalType: APP_AUTH.principalType,
principalId: APP_AUTH.principalId,
attributes: {
purpose: "team-agent",
runId: run.id,
agentId: run.agentId,
versionId: run.versionId,
resumeSource: source,
...input.attributes,
},
},
continuationToken: runToken(run.id),
title: `${run.agent.name} run`,
mode: "task",
});

return { kind: "resumed", runId, sessionId: session.id };
} catch (error) {
return {
kind: "ignored",
runId,
reason: error instanceof Error ? error.message : String(error),
};
}
}

export async function runOnSlackChannel(
channelId: string,
): Promise<string | null> {
const trimmed = channelId.trim();
if (!trimmed) return null;

const run = await db.agentRun.findFirst({
where: {
slackChannelId: trimmed,
status: { in: [...LIVE_STATUSES] },
},
orderBy: { createdAt: "desc" },
select: { id: true },
});

return run?.id ?? null;
}

export async function channelOfRun(runId: string): Promise<string | null> {
const run = await db.agentRun.findUnique({
where: { id: runId },
select: { slackChannelId: true },
});

return run?.slackChannelId ?? null;
}

export async function claimSlackChannel(
runId: string,
channelId: string,
): Promise<string | null> {
await db.agentRun.updateMany({

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When channelId is blank, claimSlackChannel persists an empty Slack channel ID even though routing treats blank IDs as absent. Reject blank IDs before updateMany so malformed channel responses cannot create inconsistent, non-routable ownership.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/run-resume.ts, line 124:

<comment>When `channelId` is blank, `claimSlackChannel` persists an empty Slack channel ID even though routing treats blank IDs as absent. Reject blank IDs before `updateMany` so malformed channel responses cannot create inconsistent, non-routable ownership.</comment>

<file context>
@@ -0,0 +1,130 @@
+	runId: string,
+	channelId: string,
+): Promise<string | null> {
+	await db.agentRun.updateMany({
+		where: { id: runId, slackChannelId: null },
+		data: { slackChannelId: channelId.trim() },
</file context>
Fix with cubic

where: { id: runId, slackChannelId: null },
data: { slackChannelId: channelId.trim() },
});

return channelOfRun(runId);
}
Loading
Loading