Skip to main content

Nango Slack Search β€” Execution Path (Plan 1 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: Add a workspace-wide Slack search capability that the Specialist AI can call as a read tool, executed server-side through the org's Nango-brokered Slack connection β€” the token never enters the agent runtime.

Architecture: A new slack_search tool (kind: 'nango') resolves to a dedicated executor branch that calls Slack's search.messages via a new Nango HTTP-proxy path (distinct from the existing Nango action path used by quickbooks/xero). The connection id is derived strictly from run.orgId (never a model parameter); results are bounded/normalized server-side.

Tech Stack: NestJS 11 + TypeScript, Jest (pure unit tests, no DB), self-hosted Nango proxy.

Scope: This is Plan 1 of 2. It builds the execution path only. Plan 2 (enablement/onboarding) β€” the Nango OAuth connect-flow for the Slack user token, slack_search credential creation on confirm, SuperAdmin catalog entry, slack_search_enabled feature flag, and docs β€” follows separately. Until Plan 2, a slack_search connection is created manually (like the PoC's import-connection.sh) for smoke testing.


File Structure​

FileResponsibilityChange
api/src/integrations/nango/nango.client.tsLow-level Nango REST callsModify β€” add proxy() (HTTP proxy)
api/src/integrations/nango/nango.service.tsOrg-aware Nango wrapperModify β€” add httpProxy()
api/src/agent-api/tool-registry.tsAuthoritative tool catalogModify β€” add slack_search entry
api/src/runtime-control-plane/runtime-tool-executor.service.tsServer-side tool executionModify β€” route slack_search β†’ new executeSlackWorkspaceSearch
api/test/nango-client-proxy.spec.tsUnit test for NangoClient.proxyCreate
api/test/nango-service-http-proxy.spec.tsUnit test for NangoService.httpProxyCreate
api/test/slack-search-tool-registry.spec.tsUnit test for the registry entryCreate
api/test/runtime-tool-executor-slack-search.spec.tsUnit test for the executor branch + isolationCreate

Commands (this repo): build = cd api && npm run build; test one spec = cd api && npx jest <path> --runInBand. PRs into dev don't run CI β€” validate locally.


Task 1: NangoClient.proxy() β€” Nango HTTP proxy call​

Files:

  • Modify: api/src/integrations/nango/nango.client.ts

  • Test: api/test/nango-client-proxy.spec.ts

  • Step 1: Write the failing test

Create api/test/nango-client-proxy.spec.ts:

import { NangoClient } from '../src/integrations/nango/nango.client';

const config = {
get: (k: string) =>
({ NANGO_SERVER_URL: 'http://nango.test', NANGO_SECRET_KEY: 'sec-123' } as Record<string, string>)[k],
} as any;

describe('NangoClient.proxy', () => {
afterEach(() => jest.restoreAllMocks());

it('GETs /proxy/<endpoint> with query params + Nango headers and returns parsed body', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ ok: true, messages: { matches: [] } }),
});
(global as any).fetch = fetchMock;

const client = new NangoClient(config);
const body = await client.proxy({
connectionId: 'acme-slack',
providerConfigKey: 'slack',
endpoint: 'search.messages',
params: { query: 'hello', count: 5 },
});

expect(body).toEqual({ ok: true, messages: { matches: [] } });
const [url, opts] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://nango.test/proxy/search.messages?query=hello&count=5');
expect(opts.method).toBe('GET');
expect(opts.headers.Authorization).toBe('Bearer sec-123');
expect(opts.headers['Provider-Config-Key']).toBe('slack');
expect(opts.headers['Connection-Id']).toBe('acme-slack');
});

it('returns a structured error (not a throw) on non-2xx', async () => {
(global as any).fetch = jest.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => JSON.stringify({ error: { message: 'boom' } }),
});
const client = new NangoClient(config);
const body = await client.proxy({ connectionId: 'acme-slack', providerConfigKey: 'slack', endpoint: 'search.messages' });
expect(body).toEqual({ status: 'error', http_status: 400, body: { error: { message: 'boom' } } });
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest test/nango-client-proxy.spec.ts --runInBand Expected: FAIL β€” client.proxy is not a function.

  • Step 3: Add the proxy method

In api/src/integrations/nango/nango.client.ts, add this method inside the NangoClient class (e.g. after listConnections, before the private readJson):

async proxy(request: {
connectionId: string;
providerConfigKey: string;
endpoint: string;
method?: string;
params?: Record<string, string | number>;
}): Promise<Record<string, unknown>> {
this.assertConfigured();

const method = (request.method ?? 'GET').toUpperCase();
const url = new URL(`${this.baseUrl}/proxy/${request.endpoint.replace(/^\/+/, '')}`);
for (const [key, value] of Object.entries(request.params ?? {})) {
url.searchParams.set(key, String(value));
}

const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.secretKey}`,
'Provider-Config-Key': request.providerConfigKey,
'Connection-Id': request.connectionId,
},
});

const body = await this.readJson(response);
if (!response.ok) {
this.logger.warn(`Nango proxy failed status=${response.status} provider=${request.providerConfigKey}`);
return { status: 'error', http_status: response.status, body };
}
return body;
}
  • Step 4: Run test to verify it passes

Run: cd api && npx jest test/nango-client-proxy.spec.ts --runInBand Expected: PASS (2 tests).

  • Step 5: Commit
git add api/src/integrations/nango/nango.client.ts api/test/nango-client-proxy.spec.ts
git commit -m "feat(nango): add HTTP proxy() to NangoClient"

Task 2: NangoService.httpProxy() β€” org-aware wrapper​

Files:

  • Modify: api/src/integrations/nango/nango.service.ts

  • Test: api/test/nango-service-http-proxy.spec.ts

  • Step 1: Write the failing test

Create api/test/nango-service-http-proxy.spec.ts:

import { NangoService } from '../src/integrations/nango/nango.service';

describe('NangoService.httpProxy', () => {
it('builds the connection id from org slug + provider and forwards to client.proxy', async () => {
const client = { proxy: jest.fn().mockResolvedValue({ ok: true }) } as any;
const service = new NangoService(client);

const res = await service.httpProxy({
orgSlug: 'Acme Inc',
provider: 'slack',
endpoint: 'search.messages',
params: { query: 'hi' },
});

expect(res).toEqual({ ok: true });
expect(client.proxy).toHaveBeenCalledWith({
connectionId: 'acme-inc-slack',
providerConfigKey: 'slack',
endpoint: 'search.messages',
method: undefined,
params: { query: 'hi' },
});
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest test/nango-service-http-proxy.spec.ts --runInBand Expected: FAIL β€” service.httpProxy is not a function.

  • Step 3: Add the httpProxy method

In api/src/integrations/nango/nango.service.ts, add inside the NangoService class (after proxy):

httpProxy(input: {
orgSlug: string;
provider: string;
endpoint: string;
method?: string;
params?: Record<string, string | number>;
}): Promise<Record<string, unknown>> {
return this.client.proxy({
connectionId: buildNangoConnectionId(input.orgSlug, input.provider),
providerConfigKey: input.provider,
endpoint: input.endpoint,
method: input.method,
params: input.params,
});
}

(buildNangoConnectionId is already exported/used in this file.)

  • Step 4: Run test to verify it passes

Run: cd api && npx jest test/nango-service-http-proxy.spec.ts --runInBand Expected: PASS (1 test).

  • Step 5: Commit
git add api/src/integrations/nango/nango.service.ts api/test/nango-service-http-proxy.spec.ts
git commit -m "feat(nango): add org-aware httpProxy() to NangoService"

Task 3: slack_search tool registry entry​

Files:

  • Modify: api/src/agent-api/tool-registry.ts

  • Test: api/test/slack-search-tool-registry.spec.ts

  • Step 1: Write the failing test

Create api/test/slack-search-tool-registry.spec.ts:

import { AGENT_TOOL_REGISTRY } from '../src/agent-api/tool-registry';

describe('slack_search tool registry entry', () => {
const tool = AGENT_TOOL_REGISTRY['slack_search'];

it('is a nango tool with the slack_search integration type', () => {
expect(tool).toBeDefined();
expect(tool.kind).toBe('nango');
expect(tool.handler).toBe('slack_search');
expect(tool.integrationTypes).toEqual(['slack_search']);
});

it('exposes a single read "search" action requiring only query', () => {
const action = tool.actions.find((a) => a.name === 'search');
expect(action).toBeDefined();
expect((action!.parameters as any).required).toEqual(['query']);
expect(Object.keys((action!.parameters as any).properties)).toEqual(
expect.arrayContaining(['query', 'count', 'sort']),
);
});

it('does NOT collide with the bespoke slack_channel_search tool', () => {
expect(AGENT_TOOL_REGISTRY['slack_channel_search'].kind).toBe('bespoke');
expect(AGENT_TOOL_REGISTRY['slack_channel_search'].integrationTypes).toEqual(['slack']);
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest test/slack-search-tool-registry.spec.ts --runInBand Expected: FAIL β€” Cannot read properties of undefined (reading 'kind').

  • Step 3: Add the registry entry

In api/src/agent-api/tool-registry.ts, add this entry to AGENT_TOOL_REGISTRY (after slack_channel_search):

slack_search: {
name: 'slack_search',
kind: 'nango',
handler: 'slack_search',
description:
"Search the org's Slack workspace (workspace-wide message search via the org's Nango-brokered Slack connection). Read-only.",
integrationTypes: ['slack_search'],
actions: [
{
name: 'search',
description:
'Search messages across the Slack workspace. Supports Slack operators (in:#channel, from:@user, after:YYYY-MM-DD).',
parameters: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: 'Slack search query' },
count: { type: 'integer', default: 5, description: 'Max results (1-20)' },
sort: { type: 'string', enum: ['score', 'timestamp'], default: 'score' },
},
},
},
],
},
  • Step 4: Run test to verify it passes

Run: cd api && npx jest test/slack-search-tool-registry.spec.ts --runInBand Expected: PASS (3 tests).

  • Step 5: Commit
git add api/src/agent-api/tool-registry.ts api/test/slack-search-tool-registry.spec.ts
git commit -m "feat(tools): register slack_search nango tool (workspace search)"

Task 4: Executor branch β€” executeSlackWorkspaceSearch​

Files:

  • Modify: api/src/runtime-control-plane/runtime-tool-executor.service.ts

  • Test: api/test/runtime-tool-executor-slack-search.spec.ts

  • Step 1: Write the failing test

Create api/test/runtime-tool-executor-slack-search.spec.ts:

import { NotFoundException } from '@nestjs/common';
import { HumanWorkRuntimeToolExecutor } from '../src/runtime-control-plane/runtime-tool-executor.service';

const SLACK_OK = {
ok: true,
messages: {
total: 2,
matches: [
{ channel: { name: 'general' }, username: 'amy', ts: '1', permalink: 'http://p/1', text: 'x'.repeat(600) },
{ channel: { id: 'C2' }, user: 'U2', ts: '2', permalink: 'http://p/2', text: 'short' },
],
},
};

function make(nangoResult: any = SLACK_OK, org: any = { id: 'org-1', slug: 'acme' }) {
const credRepo = { findOne: jest.fn().mockResolvedValue({ orgId: 'org-1', integrationType: 'slack_search' }) };
const orgRepo = { findOne: jest.fn().mockResolvedValue(org) };
const nango = { proxy: jest.fn(), httpProxy: jest.fn().mockResolvedValue(nangoResult) };
const executor = new HumanWorkRuntimeToolExecutor(credRepo as any, orgRepo as any, nango as any);
return { executor, credRepo, orgRepo, nango };
}

const input = (params: Record<string, unknown>) =>
({ orgId: 'org-1', runId: 'run-1', toolCallId: 't-1', toolName: 'slack_search', action: 'search', params, correlationId: 'c-1' } as any);

describe('executeSlackWorkspaceSearch', () => {
it('derives connection from orgId→slug (NOT model params) and normalizes+bounds results', async () => {
const { executor, nango } = make();
const res = await executor.execute(input({ query: 'hello', count: 3, sort: 'timestamp', orgId: 'evil', connectionId: 'evil' }));

// connection derived from resolved org slug β€” model-supplied orgId/connectionId ignored
expect(nango.httpProxy).toHaveBeenCalledWith({
orgSlug: 'acme',
provider: 'slack_search',
method: 'GET',
endpoint: 'search.messages',
params: { query: 'hello', count: 3, sort: 'timestamp', sort_dir: 'desc' },
});
expect(res.ok).toBe(true);
expect(res.total).toBe(2);
const results = res.results as any[];
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ channel: 'general', user: 'amy', ts: '1', permalink: 'http://p/1', text: 'x'.repeat(500) });
expect(results[1].channel).toBe('C2');
});

it('clamps count to 1..20 and defaults sort to score', async () => {
const { executor, nango } = make();
await executor.execute(input({ query: 'q', count: 999 }));
expect(nango.httpProxy).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ count: 20, sort: 'score' }) }));
});

it('maps Slack error envelopes to a structured, hinted result', async () => {
const { executor } = make({ ok: false, error: 'missing_scope' });
const res = await executor.execute(input({ query: 'q' }));
expect(res.ok).toBe(false);
expect(res.error).toBe('missing_scope');
expect(String(res.hint)).toContain('search:read');
});

it('maps a Nango proxy failure to slack_search_unavailable', async () => {
const { executor } = make({ status: 'error', http_status: 502, body: {} });
const res = await executor.execute(input({ query: 'q' }));
expect(res).toEqual({ ok: false, error: 'slack_search_unavailable', detail: 'Nango proxy HTTP 502' });
});

it('hard-fails when the org has no slack_search connection', async () => {
const { executor } = make();
(executor as any).credRepo = { findOne: jest.fn().mockResolvedValue(null) };
const c = { findOne: jest.fn().mockResolvedValue(null) };
const o = { findOne: jest.fn().mockResolvedValue({ id: 'org-1', slug: 'acme' }) };
const ex2 = new HumanWorkRuntimeToolExecutor(c as any, o as any, { httpProxy: jest.fn() } as any);
await expect(ex2.execute(input({ query: 'q' }))).rejects.toBeInstanceOf(NotFoundException);
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest test/runtime-tool-executor-slack-search.spec.ts --runInBand Expected: FAIL β€” the nango branch calls executeNango (β†’ nango.proxy), so httpProxy is never called / result not normalized.

  • Step 3: Route slack_search + implement the branch

In api/src/runtime-control-plane/runtime-tool-executor.service.ts, change the nango dispatch in execute():

if (toolDef.kind === 'nango') {
if (input.toolName === 'slack_search') {
return this.executeSlackWorkspaceSearch(input);
}
return this.executeNango(input, toolDef);
}

Then add these private methods (e.g. after executeSlackSearch):

private async executeSlackWorkspaceSearch(input: RuntimeToolExecutorInput): Promise<Record<string, unknown>> {
if (!this.nangoService) throw new BadRequestException('Nango service is not available');
if (input.action !== 'search') {
throw new BadRequestException(`Slack action '${input.action}' is not implemented`);
}

// Connection is derived STRICTLY from the run's org β€” never from model input.
const org = await this.orgRepo.findOne({ where: { id: input.orgId } });
const orgSlug = typeof org?.slug === 'string' ? org.slug.trim() : '';
if (!orgSlug) throw new NotFoundException('Organisation slug not found');

const query = this.requireStringParam(input.params, ['query'], 'Slack search query');
const count = this.boundSlackCount(input.params.count);
const sort = input.params.sort === 'timestamp' ? 'timestamp' : 'score';

// provider 'slack_search' (NOT 'slack') β€” the Nango integration unique_key +
// integration_type is slack_search, so connectionId is `{slug}-slack-search`,
// never colliding with the bespoke bot path's integration_type 'slack'.
const raw = await this.nangoService.httpProxy({
orgSlug,
provider: 'slack_search',
method: 'GET',
endpoint: 'search.messages',
params: { query, count, sort, sort_dir: 'desc' },
});

return this.normalizeSlackWorkspaceSearch(raw);
}

private boundSlackCount(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return 5;
return Math.max(1, Math.min(20, Math.trunc(n)));
}

private normalizeSlackWorkspaceSearch(raw: Record<string, unknown>): Record<string, unknown> {
// Nango-level failure (proxy returned non-2xx) β€” see NangoClient.proxy.
if (raw?.status === 'error') {
return { ok: false, error: 'slack_search_unavailable', detail: `Nango proxy HTTP ${raw.http_status}` };
}
// Slack API-level failure envelope.
if (raw?.ok === false) {
const error = String(raw.error ?? 'unknown_error');
const hints: Record<string, string> = {
missing_scope: "Slack connection is missing the 'search:read' scope β€” reconnect required.",
not_allowed_token_type: 'Slack search requires a user token (xoxp), not a bot token.',
ratelimited: 'Slack rate limit reached β€” try again shortly.',
token_revoked: 'Slack connection was revoked β€” reconnect required.',
account_inactive: 'Slack connection is inactive β€” reconnect required.',
};
return { ok: false, error, hint: hints[error] };
}
const messagesObj = (raw?.messages ?? {}) as Record<string, unknown>;
const matches = Array.isArray(messagesObj.matches) ? (messagesObj.matches as Record<string, unknown>[]) : [];
const results = matches.slice(0, 20).map((m) => {
const channel = m?.channel as Record<string, unknown> | undefined;
return {
channel: (channel?.name as string) ?? (channel?.id as string) ?? null,
user: (m?.username as string) ?? (m?.user as string) ?? null,
ts: (m?.ts as string) ?? null,
permalink: (m?.permalink as string) ?? null,
text: typeof m?.text === 'string' ? (m.text as string).slice(0, 500) : '',
};
});
return { ok: true, total: (messagesObj.total as number) ?? results.length, results };
}
  • Step 4: Run test to verify it passes

Run: cd api && npx jest test/runtime-tool-executor-slack-search.spec.ts --runInBand Expected: PASS (5 tests).

  • Step 5: Run the existing executor spec (no regressions) + build

Run: cd api && npx jest test/runtime-tool-executor.service.spec.ts --runInBand && npm run build Expected: existing executor tests still PASS; build succeeds.

  • Step 6: Commit
git add api/src/runtime-control-plane/runtime-tool-executor.service.ts api/test/runtime-tool-executor-slack-search.spec.ts
git commit -m "feat(agent-tools): execute slack_search via Nango HTTP proxy (server-derived connection)"

Self-Review​

Spec coverage (against 2026-07-21-nango-slack-search-phase1-design.md):

  • Β§4.1 NangoClient.proxy() β†’ Task 1 βœ…
  • Β§4.1 NangoService.httpProxy() β†’ Task 2 βœ…
  • Β§4.2 slack_search registry entry β†’ Task 3 βœ…
  • Β§4.3 executor branch + Β§5 result bounding + Β§6 error mapping β†’ Task 4 βœ…
  • Β§5 server-derived connection id (isolation) β†’ Task 4 test "derives connection from orgIdβ†’slug (NOT model params)" βœ…
  • Β§4.4 credential coexistence / "connection exists" gate β†’ enforced by existing requireOrgCredential on integrationTypes: ['slack_search']; Task 4 test "hard-fails when the org has no slack_search connection" βœ…
  • Deferred to Plan 2 (by design): connect-flow/OAuth (Β§3), credential creation on confirm (Β§4.4), SA catalog + slack_search_enabled flag (Β§8), MCP schema-surfacing detail (Β§4.6), docs (Β§10), ADR-020 matrix row + entity docstring (Β§5 β€” lands with the credential-type work in Plan 2).

Placeholder scan: none β€” every step has concrete code/commands.

Type consistency: proxy({connectionId, providerConfigKey, endpoint, method?, params?}) is defined identically in Task 1 (client), consumed in Task 2 (service) and Task 4 (via httpProxy). httpProxy({orgSlug, provider, endpoint, method?, params?}) defined in Task 2, called in Task 4 with exactly those keys. Return-shape {status:'error', http_status, body} produced in Task 1, consumed in Task 4's normalizeSlackWorkspaceSearch. Consistent.


Execution Handoff​

Two execution options:

  1. Subagent-Driven (recommended) β€” fresh subagent per task, review between tasks.
  2. Inline Execution β€” execute tasks in this session with checkpoints.

Note: Plan 2 (enablement/onboarding) should be written before this feature is usable end-to-end in the app; Plan 1 is unit-complete and manually smoke-testable via a hand-imported connection.