Nango Slack Search โ Enablement & Onboarding (Plan 2 of 2) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the slack_search tool (built in Plan 1) reachable end-to-end and client-self-serve: OAuth-connectable via the existing Nango connect-flow, gated by a per-org kill switch, and enabled by SuperAdmin โ without touching the bespoke Slack bot path.
Architecture: slack_search is treated as a Nango-backed integration type (mirrors quickbooks/xero): the connect-flow allow-list is widened, the credential type is registered, a slack_search_enabled ORG-scope feature flag (default OFF) gates advertisement in the /chat resolver, and SuperAdmin enables the ToolPermission row (deny-by-default for Nango tools already enforces gating).
Tech Stack: NestJS 11 + TypeScript, class-validator DTOs, feature-flags service (DB-backed), Jest.
Depends on: Plan 1 (execution path). This plan assumes AGENT_TOOL_REGISTRY['slack_search'] exists with kind: 'nango', handler: 'slack_search', integrationTypes: ['slack_search'].
Naming (locked): the Nango provider config key, the local integration_type, and the connect DTO provider value are all slack_search (NOT slack), so the connection id is {org_slug}-slack-search and never collides with the bespoke bot path's integration_type: 'slack'.
File Structureโ
| File | Responsibility | Change |
|---|---|---|
api/src/feature-flags/feature-flags.constants.ts | Flag registry + defaults | Modify โ add slack_search_enabled |
api/src/feature-flags/feature-flag.service.spec.ts | Exact-array guard | Modify โ append the key |
api/src/integrations/credentials/credentials.dto.ts | Connect DTOs + ChannelType | Modify โ allow slack_search |
api/src/integrations/credentials/credentials.service.ts | Credential validation/lifecycle | Modify โ register slack_search as Nango-backed |
api/src/conversations/tool-resolution.util.ts | Tool intersection helpers | Modify โ add flag-gating helper |
api/src/conversations/conversations.service.ts | /chat tool resolver | Modify โ apply the flag gate |
api/src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts | Provider validation test | Create |
api/test/tool-resolution-flag-gate.spec.ts | Flag-gate helper test | Create |
docs/integrations/NANGO_SETUP.md, api/src/integrations/CLAUDE.md, root CLAUDE.md | Docs/runbook + ADR-020 row | Modify |
Commands: build = cd api && npm run build; test = cd api && npx jest <path> --runInBand.
Task 1: Register the slack_search_enabled feature flag (ORG scope, default OFF)โ
Files:
-
Modify:
api/src/feature-flags/feature-flags.constants.ts -
Modify (test):
api/src/feature-flags/feature-flag.service.spec.ts -
Step 1: Update the exact-array guard test first (it will fail)
In api/src/feature-flags/feature-flag.service.spec.ts, the it("keeps seeded keys in the FlagKey union") assertion (~line 39) lists FLAG_KEYS verbatim. Append "slack_search_enabled" as the last element of that toEqual([...]) array (immediately before the closing ])):
// ...existing keys...
"slack_search_enabled",
]);
- Step 2: Run it to verify it fails
Run: cd api && npx jest src/feature-flags/feature-flag.service.spec.ts -t "keeps seeded keys" --runInBand
Expected: FAIL โ array has extra slack_search_enabled not in FLAG_KEYS.
- Step 3: Add the flag key + default
In api/src/feature-flags/feature-flags.constants.ts:
- Append
"slack_search_enabled"as the last entry of theFLAG_KEYStuple (before] as const):
"slack_search_enabled",
] as const;
- Add to
DEFAULT_FLAG_VALUES(default OFF, mirroringtool_dispatch_enabled):
slack_search_enabled: false,
- Step 4: Run it to verify it passes
Run: cd api && npx jest src/feature-flags/feature-flag.service.spec.ts --runInBand
Expected: PASS (whole file).
- Step 5: Commit
git add api/src/feature-flags/feature-flags.constants.ts api/src/feature-flags/feature-flag.service.spec.ts
git commit -m "feat(flags): add slack_search_enabled (org scope, default off)"
Task 2: Accept slack_search as a Nango-backed provider (DTO + service)โ
Files:
-
Modify:
api/src/integrations/credentials/credentials.dto.ts -
Modify:
api/src/integrations/credentials/credentials.service.ts -
Test:
api/src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts -
Step 1: Write the failing test
Create api/src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts:
import { BadRequestException } from "@nestjs/common";
import { CredentialsService } from "../credentials.service";
// Mirrors credentials.nango-kb-providers.spec.ts. Constructor arg order:
// (credRepo, orgRepo, slackBindingRepo, nangoClient, telegramOnboarding, featureFlags, auditService)
function makeService(overrides: { credRepo?: any } = {}) {
const createConnectSession = jest.fn().mockResolvedValue({ token: "sess-token" });
const getConnection = jest.fn().mockResolvedValue({ credentials: {} });
const nangoClient = { createConnectSession, getConnection } as any;
const orgRepo = { findOne: jest.fn().mockResolvedValue({ id: "org-1", slug: "acme" }) } as any;
const credRepo = overrides.credRepo ?? {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((x: any) => x),
save: jest.fn(async (x: any) => ({ id: "cred-1", ...x })),
};
const svc = new CredentialsService(
credRepo, orgRepo, {} as any, nangoClient,
{ setup: jest.fn() } as any, { isEnabled: jest.fn() } as any, { tryLog: jest.fn() } as any,
);
return { svc, createConnectSession, getConnection, credRepo };
}
describe("CredentialsService โ slack_search Nango provider", () => {
it("creates a Connect session with connection id {slug}-slack-search", async () => {
const { svc, createConnectSession } = makeService();
const res = await svc.createNangoSession("org-1", { userId: "u1", email: "a@b.com" }, { provider: "slack_search" } as any);
expect(res.sessionToken).toBe("sess-token");
expect(res.connectionId).toBe("acme-slack-search");
expect(createConnectSession).toHaveBeenCalledWith(
expect.objectContaining({ providerConfigKey: "slack_search" }),
);
});
it("persists a slack_search credential row on confirm", async () => {
const { svc, credRepo } = makeService();
await svc.confirmNangoConnection("org-1", { provider: "slack_search", nango_connection_id: "acme-slack-search" } as any);
expect(credRepo.create).toHaveBeenCalledWith(expect.objectContaining({ orgId: "org-1", integrationType: "slack_search" }));
});
it("still rejects the bespoke 'slack' type as a Nango provider", async () => {
const { svc } = makeService();
await expect(
svc.createNangoSession("org-1", { userId: "u1" }, { provider: "slack" } as any),
).rejects.toBeInstanceOf(BadRequestException);
});
});
- Step 2: Run test to verify it fails
Run: cd api && npx jest src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts --runInBand
Expected: FAIL โ validateNangoProvider rejects slack_search (not in NANGO_BACKED_TYPES).
- Step 3: Widen the DTO +
ChannelType
In api/src/integrations/credentials/credentials.dto.ts:
- Add
| 'slack_search'to theChannelTypeunion (after'google_drive'):
| 'google_drive'
| 'slack_search'
| 'jumio';
- In
NangoSessionDtoandNangoConfirmDto, widen both the@IsIn([...])array and theproviderfield type:
export class NangoSessionDto {
@IsString()
@IsIn(['quickbooks', 'xero', 'slack_search'])
provider: 'quickbooks' | 'xero' | 'slack_search';
}
export class NangoConfirmDto {
@IsString()
@IsIn(['quickbooks', 'xero', 'slack_search'])
provider: 'quickbooks' | 'xero' | 'slack_search';
@IsString()
@IsNotEmpty()
nango_connection_id: string;
}
- Step 4: Register the type as Nango-backed in the service
In api/src/integrations/credentials/credentials.service.ts:
- Add to
CHANNEL_REQUIRED_KEYS(no local config keys โ creds live in Nango):
slack_search: [],
- Add to the
NANGO_BACKED_TYPESset:
const NANGO_BACKED_TYPES = new Set<ChannelType>([
"quickbooks", "xero", "notion", "google_drive", "slack_search",
]);
- Step 5: Run test to verify it passes
Run: cd api && npx jest src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts --runInBand && npm run build
Expected: PASS (3 tests); build succeeds.
- Step 6: Commit
git add api/src/integrations/credentials/credentials.dto.ts api/src/integrations/credentials/credentials.service.ts api/src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.ts
git commit -m "feat(integrations): register slack_search as a Nango-backed provider"
Task 3: Gate slack_search advertisement behind the flag in the /chat resolverโ
Files:
-
Modify:
api/src/conversations/tool-resolution.util.ts -
Modify:
api/src/conversations/conversations.service.ts -
Test:
api/test/tool-resolution-flag-gate.spec.ts -
Step 1: Write the failing test
Create api/test/tool-resolution-flag-gate.spec.ts:
import { FLAG_GATED_TOOLS, splitFlagGatedTools } from '../src/conversations/tool-resolution.util';
describe('flag-gated tool split', () => {
it('maps slack_search to slack_search_enabled', () => {
expect(FLAG_GATED_TOOLS['slack_search']).toBe('slack_search_enabled');
});
it('separates gated from ungated slugs', () => {
const { ungated, gated } = splitFlagGatedTools(['order_lookup', 'slack_search', 'shopify_query']);
expect(ungated).toEqual(['order_lookup', 'shopify_query']);
expect(gated).toEqual([{ slug: 'slack_search', flagKey: 'slack_search_enabled' }]);
});
it('returns everything ungated when no gated tool present', () => {
const { ungated, gated } = splitFlagGatedTools(['order_lookup']);
expect(ungated).toEqual(['order_lookup']);
expect(gated).toEqual([]);
});
});
- Step 2: Run test to verify it fails
Run: cd api && npx jest test/tool-resolution-flag-gate.spec.ts --runInBand
Expected: FAIL โ splitFlagGatedTools / FLAG_GATED_TOOLS not exported.
- Step 3: Add the helper
Append to api/src/conversations/tool-resolution.util.ts:
/**
* Tools that require an ORG-scope feature flag to be advertised at all
* (default OFF). Maps tool slug โ flag key. A gated tool is dropped from the
* /chat manifest unless its flag resolves true for the org. This is an extra
* kill switch on top of ToolPermission deny-by-default + published skill/binding.
*/
export const FLAG_GATED_TOOLS: Record<string, string> = {
slack_search: 'slack_search_enabled',
};
export function splitFlagGatedTools(slugs: string[]): {
ungated: string[];
gated: { slug: string; flagKey: string }[];
} {
const ungated: string[] = [];
const gated: { slug: string; flagKey: string }[] = [];
for (const slug of slugs ?? []) {
const flagKey = FLAG_GATED_TOOLS[slug];
if (flagKey) gated.push({ slug, flagKey });
else ungated.push(slug);
}
return { ungated, gated };
}
- Step 4: Run test to verify it passes
Run: cd api && npx jest test/tool-resolution-flag-gate.spec.ts --runInBand
Expected: PASS (3 tests).
- Step 5: Wire the gate into
resolveSpecialistToolsets
In api/src/conversations/conversations.service.ts, ensure FlagKey and the new helper are imported from their modules (the file already imports from ./tool-resolution.util and the feature-flags constants โ add splitFlagGatedTools to the tool-resolution import and FlagKey to the feature-flags-constants import if not already present).
Then replace the tail of resolveSpecialistToolsets (from const intersection = intersectTools(...) onward) with:
const intersection = intersectTools(skills, bindings);
if (!intersection.length) return EMPTY_SPECIALIST_TOOLS;
// Per-tool ORG-scope kill switch (default OFF): drop a gated tool unless
// its flag is enabled for this org.
const { ungated, gated } = splitFlagGatedTools(intersection);
const enabledGated: string[] = [];
for (const g of gated) {
const on = await this.featureFlags?.isEnabled(g.flagKey as FlagKey, { orgId });
if (on === true) enabledGated.push(g.slug);
}
const finalSlugs = [...ungated, ...enabledGated].sort();
if (!finalSlugs.length) return EMPTY_SPECIALIST_TOOLS;
const mcpTools = expandToNameAction(finalSlugs);
return { toolsetNames: mcpTools.length ? ["humanwork"] : [], mcpTools };
- Step 6: Verify no regressions + build
Run: cd api && npx jest src/conversations/tool-dispatch-wiring.spec.ts --runInBand && npm run build
Expected: existing tool-dispatch wiring tests PASS; build succeeds.
- Step 7: Commit
git add api/src/conversations/tool-resolution.util.ts api/src/conversations/conversations.service.ts api/test/tool-resolution-flag-gate.spec.ts
git commit -m "feat(chat): gate slack_search advertisement behind slack_search_enabled flag"
Task 4: Docs, ADR-020 row, and the operational runbookโ
Files:
-
Modify: root
CLAUDE.md(ADR-020 isolation matrix) -
Modify:
api/src/integrations/CLAUDE.md -
Modify:
docs/integrations/NANGO_SETUP.md -
Step 1: Add the ADR-020 matrix row
In root CLAUDE.md, in the "Multi-tenancy isolation matrix" table, add a row:
| Client external-account tokens (Slack search) | **per-Org** Nango connection | `slack_search` credential (`nango_connection_id` only) resolved **strictly by `run.orgId`**; connection id server-derived (`{slug}-slack-search`); token stored only in Nango, never in Expert/Agent context |
- Step 2: Document the type in the integrations module doc
In api/src/integrations/CLAUDE.md, under Nango-backed types, note: slack_search is a Nango-backed user-token connection (scope search:read) for workspace search, distinct from the bespoke slack bot credential; NangoClient.proxy() / NangoService.httpProxy() route search.messages through Nango; connection id {slug}-slack-search; delete revokes upstream via deleteConnection.
- Step 3: Add the HP setup + enablement runbook
In docs/integrations/NANGO_SETUP.md, add a "Slack search (slack_search)" section:
## Slack search (slack_search)
Workspace-wide Slack search for the Specialist AI, via a Nango-brokered Slack
**user** token (scope `search:read`). Separate from the bespoke Slack bot channel.
### One-time HP setup (per environment's Nango)
1. Create ONE Slack app (api.slack.com/apps). Add **User Token Scope** `search:read`.
2. Add the Nango callback URL to the app's Redirect URLs.
3. Enable **public distribution** (so client workspaces can install via OAuth).
4. In Nango, create an integration with **unique_key `slack_search`** referencing the
`slack` provider template; set the app's client id/secret and the `search:read` user scope.
### Per-client enablement
1. SuperAdmin: `PUT /orgs/:orgId/tool-permissions/slack_search` `{ "enabled": true }`.
2. Set feature flag `slack_search_enabled = true` for the org.
3. Publish a Specialist skill listing `slack_search` + a `slack_search` tool binding.
4. Client admin connects: `POST /v1/integrations/nango/session { "provider": "slack_search" }`
โ consent โ `POST /v1/integrations/credentials/nango/confirm { "provider": "slack_search", "nango_connection_id": "<slug>-slack-search" }`.
Recommend a dedicated service user for broad, stable search visibility.
- Step 4: Commit
git add CLAUDE.md api/src/integrations/CLAUDE.md docs/integrations/NANGO_SETUP.md
git commit -m "docs: slack_search isolation row, module notes, and enablement runbook"
Self-Reviewโ
Spec coverage (against the Phase 1 design):
- ยง3 OAuth model / connect-flow allow-list โ Task 2 (DTO + service) โ
- ยง4.4 Nango-backed credential type
slack_search+ delete โ Task 2 (NANGO_BACKED_TYPESgives create+delete) โ - ยง5 ADR-020 matrix row โ Task 4 โ
- ยง8
slack_search_enabledflag (ORG, default OFF) + gating โ Task 1 + Task 3 โ - ยง8 SuperAdmin catalog enablement โ Task 4 runbook (uses existing
PUT /orgs/:orgId/tool-permissions/:toolName; deny-by-default already enforced for Nango tools) โ - ยง10 docs-to-keep-alive โ Task 4 โ
- Not covered (acknowledged): the MCP schema-surfacing detail (ยง4.6) โ how
slack_search's param schema reaches the model through the forwarder โ is verified operationally during pilot (Task 4 runbook step: confirm the agent callsslack_searchwith aquery); if the forwarder advertises onlyname:action, a follow-up is filed to pass the registry schema through. Flagged as an open item, not silently dropped.
Placeholder scan: none โ concrete code/commands throughout. (Task 1 & the ADR row append to lists whose full contents live in-repo; the instruction pins the exact position โ "last entry" โ and the added text.)
Type consistency: slack_search used identically as DTO provider, integration_type, and Nango providerConfigKey across Tasks 2โ4; FLAG_GATED_TOOLS/splitFlagGatedTools defined in Task 3 Step 3 and consumed in Step 5; slack_search_enabled flag key defined in Task 1 and referenced in Task 3. Connection id {slug}-slack-search consistent with Plan 1's httpProxy(provider: 'slack_search').
Execution Handoffโ
Same two options as Plan 1 (subagent-driven recommended). Recommended overall order: Plan 1 โ Plan 2, then the Task-4 runbook (HP Slack app + pilot org enablement) as an operational step, then staging verification.