Skip to main content

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โ€‹

FileResponsibilityChange
api/src/feature-flags/feature-flags.constants.tsFlag registry + defaultsModify โ€” add slack_search_enabled
api/src/feature-flags/feature-flag.service.spec.tsExact-array guardModify โ€” append the key
api/src/integrations/credentials/credentials.dto.tsConnect DTOs + ChannelTypeModify โ€” allow slack_search
api/src/integrations/credentials/credentials.service.tsCredential validation/lifecycleModify โ€” register slack_search as Nango-backed
api/src/conversations/tool-resolution.util.tsTool intersection helpersModify โ€” add flag-gating helper
api/src/conversations/conversations.service.ts/chat tool resolverModify โ€” apply the flag gate
api/src/integrations/credentials/__tests__/credentials.slack-search-provider.spec.tsProvider validation testCreate
api/test/tool-resolution-flag-gate.spec.tsFlag-gate helper testCreate
docs/integrations/NANGO_SETUP.md, api/src/integrations/CLAUDE.md, root CLAUDE.mdDocs/runbook + ADR-020 rowModify

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 the FLAG_KEYS tuple (before ] as const):
"slack_search_enabled",
] as const;
  • Add to DEFAULT_FLAG_VALUES (default OFF, mirroring tool_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 the ChannelType union (after 'google_drive'):
| 'google_drive'
| 'slack_search'
| 'jumio';
  • In NangoSessionDto and NangoConfirmDto, widen both the @IsIn([...]) array and the provider field 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_TYPES set:
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_TYPES gives create+delete) โœ…
  • ยง5 ADR-020 matrix row โ†’ Task 4 โœ…
  • ยง8 slack_search_enabled flag (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 calls slack_search with a query); if the forwarder advertises only name: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.