Slack Archive 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: Periodic sync of a client's Slack channel history into a provider-generic archive envelope ledger (Postgres) + a dedicated slack-archive Haystack dataset, searchable by the Specialist through a governed, flag-gated read tool β fully separate from the KB.
Architecture: Own tables + own dataset + direct HaystackClient calls β the archive never creates org_documents rows, so it is structurally invisible to the KB list, the Tavus conversational-context builder, the draft KB retrieval path (source: "kb" β default-kb), and the FORCE-RLS ACL post-filter. Sync follows the P4.8 connector pattern (leader-gated scheduler + BullMQ fan-out). Envelopes (archive_records) are the replay substrate: reindexing never re-fetches Slack. Content lands raw ("author, date, text" β no scrubbing, explicit product decision). Default sync start: yesterday.
Tech Stack: NestJS 11, TypeORM + PG migration, BullMQ, Nango (slack_archive user-token integration: channels:read,channels:history,users:read), Haystack/pgvector via existing HaystackClient, tool gateway (AGENT_TOOL_REGISTRY β runtime-tool-executor), feature flags.
Branch: feat/slack-archive (off origin/dev).
Execution status: Tasks 1β5 assigned to subagent ArchivePipeline, Tasks 6β7 to subagent ArchiveTool (running in parallel; disjoint file ownership). Tasks 8β10 = integrator (main session).
File Structureβ
api/migrations/1811200000000-CreateSlackArchive.ts (create)
api/src/slack-archive/slack-archive.entities.ts (create: 3 entities)
api/src/slack-archive/slack-archive-sync.service.ts (create: leader-gated scheduler)
api/src/slack-archive/slack-archive-fetch.processor.ts (create: fetch β envelopes β day-docs β ingest)
api/src/slack-archive/slack-archive-config.service.ts (create)
api/src/slack-archive/slack-archive.controller.ts (create: orgs/:orgId/slack-archive)
api/src/slack-archive/slack-archive.module.ts (create)
api/src/haystack/archive-retrieval.service.ts (create: dataset-scoped search)
api/src/haystack/haystack.types.ts (modify: widen HaystackDataset)
api/src/agent-api/tool-registry.ts (modify: slack_archive_search entry)
api/src/runtime-control-plane/runtime-tool-executor.service.ts (modify: handler 'slack_archive')
api/src/conversations/tool-resolution.util.ts (modify: FLAG_GATED_TOOLS)
api/src/feature-flags/feature-flags.constants.ts (modify: slack_archive_enabled)
api/src/integrations/credentials/{credentials.dto,credentials.service,sources.service}.ts (modify: provider slack_archive)
api/src/scheduler/scheduler.constants.ts (modify: SLACK_ARCHIVE_SYNC)
api/src/config/typeorm-data-source.config.ts + api/src/data-source.ts (modify: entity registration β BOTH)
api/src/app.module.ts (modify: SlackArchiveModule β Task 8 only)
Locked contracts (all tasks MUST match)β
- Dataset:
"slack-archive"Β· Nango provider/integration type:slack_archiveΒ· Tool:slack_archive_search(kindbespoke, handlerslack_archive) Β· Flag:slack_archive_enabled(default OFF) Β· Queue:slack-archive-fetchΒ· Scheduler job:SLACK_ARCHIVE_SYNC, envSLACK_ARCHIVE_SYNC_CRONdefault*/15 * * * *. - Envelope (user-specified, plus isolation fields):
provider β 'slack'|'github'|'notion' (text, no DB enum). doc_type β 'message'|'comment'|'issue'. For Slack:archive_records(id uuid PK, org_id uuid NOT NULL, source_id uuid NOT NULL FKβslack_archive_sources ON DELETE CASCADE,provider text NOT NULL, doc_type text NOT NULL, doc_external_id text NOT NULL,raw_data text NOT NULL, sha text NOT NULL, meta jsonb NOT NULL DEFAULT '{}',created_at timestamptz DEFAULT now(), UNIQUE(org_id, provider, doc_external_id))
doc_external_id = "${channelId}:${message.ts}",raw_data = "[YYYY-MM-DD HH:MM] <author>: <text>",sha = sha256(raw_data),meta = {channel_id, channel_name, author, ts}. - Config:
slack_archive_sources(id, org_id UNIQUE, credential_id, enabled bool DEFAULT false, channels jsonb '[]', include_future bool DEFAULT false, since_date date DEFAULT (CURRENT_DATE - 1), cursors jsonb '{}', last_synced_at, last_error, updated_by, created_at, updated_at). - Index state:
archive_index_state(id, org_id, source_id, channel_id, day date, content_hash, haystack_doc_id, message_count int, last_ingested_at, created_at, updated_at, UNIQUE(org_id, channel_id, day)). - Day-doc: header
#<channel_name> β <day>+ one line per message in enveloperaw_dataorder (by ts). Haystack doc id${orgId}:slack-archive:${contentHash}; ingest meta{dataset:"slack-archive", org_id, specialist_id:null, audience:"ai_internal", channel_id, channel_name, day, provider:"slack"}. - ADR-020: every entity docstring names its row (Org-scoped; served to agents only through the org-filtered
slack-archivedataset; never joined into KB surfaces). All queries filterorg_id.
Task 1: Migration + entities (owner: ArchivePipeline)β
Files: Create api/migrations/1811200000000-CreateSlackArchive.ts, api/src/slack-archive/slack-archive.entities.ts; Modify both entity arrays.
- Step 1: Read a recent migration (e.g.
ls api/migrations | tail) and mirror its class shape. Write the migration creating the three tables from Locked contracts, plus indexes:archive_records(org_id),archive_index_state(org_id),slack_archive_sources(org_id)(UNIQUE). - Step 2: Entities:
/**
* ADR-020: Org-scoped connector configuration (one row per org's Slack
* workspace archive). Not row-1 PII in itself; the archived CONTENT is served
* to agents only through the org-filtered "slack-archive" Haystack dataset β
* never joined into KB surfaces (kb list, Tavus context, draft retrieval).
*/
@Entity("slack_archive_sources")
export class SlackArchiveSource {
@PrimaryGeneratedColumn("uuid") id: string;
@Column({ name: "org_id", type: "uuid" }) orgId: string;
@Column({ name: "credential_id", type: "uuid" }) credentialId: string;
@Column({ default: false }) enabled: boolean;
@Column({ type: "jsonb", default: () => "'[]'" }) channels: Array<{ id: string; name: string }>;
@Column({ name: "include_future", default: false }) includeFuture: boolean;
@Column({ name: "since_date", type: "date" }) sinceDate: string;
@Column({ type: "jsonb", default: () => "'{}'" }) cursors: Record<string, string>;
@Column({ name: "last_synced_at", type: "timestamptz", nullable: true }) lastSyncedAt: Date | null;
@Column({ name: "last_error", type: "text", nullable: true }) lastError: string | null;
@Column({ name: "updated_by", type: "uuid", nullable: true }) updatedBy: string | null;
@CreateDateColumn({ name: "created_at" }) createdAt: Date;
@UpdateDateColumn({ name: "updated_at" }) updatedAt: Date;
}
ArchiveRecord and ArchiveIndexState follow the same style with the Locked-contract columns (snake_case column names, camelCase properties).
- Step 3: Register all three entities in
api/src/config/typeorm-data-source.config.tsANDapi/src/data-source.ts(both βforFeaturealone throws "No metadata" at runtime, #3189). - Step 4:
cd api && npx tsc --noEmitfor the touched files compile. Commit:feat(slack-archive): schema + entities.
Task 2: Nango provider slack_archive (owner: ArchivePipeline)β
Files: Modify api/src/integrations/credentials/credentials.dto.ts, credentials.service.ts, sources.service.ts.
- Step 1: Mirror every
slack_searchtouchpoint: ChannelType union +NANGO_CONNECT_PROVIDERS+NANGO_BACKED_TYPES+CHANNEL_REQUIRED_KEYS: { slack_archive: [] }+NANGO_USER_SCOPES: { slack_archive: "channels:read,channels:history,users:read" }+validateNangoProvidermessage +SOURCE_TOOL_BY_PROVIDER: { slack_archive: "slack_archive_search" }+ display name"Slack History". - Step 2: Extend the existing Nango-provider spec pattern (
credentials.slack-search-provider.spec.tsstyle) with aslack_archivecase asserting the session carriesuser_scopes: "channels:read,channels:history,users:read". Run only that spec. Commit.
Task 3: Sync service (owner: ArchivePipeline)β
Files: Create api/src/slack-archive/slack-archive-sync.service.ts; Modify api/src/scheduler/scheduler.constants.ts.
- Step 1: Clone the structure of
api/src/kb/kb-connector-sync.service.ts:onModuleInitself-registration under newSCHEDULER_JOBS.SLACK_ARCHIVE_SYNC, cron fromSLACK_ARCHIVE_SYNC_CRON(default*/15 * * * *), body leader-gated viaLeaderElectionService.acquireLeadership. - Step 2: Body:
SELECT slack_archive_sources WHERE enabled = true, join credential (integration_credentials.integration_type = 'slack_archive', status active); per source inside try/catch (failure β log +last_error, continue siblings): enqueue{ sourceId, orgId }on queueslack-archive-fetchwith a stablejobId(slack-archive:${sourceId}β dedupes overlapping ticks). - Step 3: Spec
api/test/slack-archive-sync.spec.ts: picks enabled+active; skips disabled/inactive; no-ops when leadership not acquired; one failing source doesn't block the next. Run only it. Commit.
Task 4: Fetch processor (owner: ArchivePipeline)β
Files: Create api/src/slack-archive/slack-archive-fetch.processor.ts (queue slack-archive-fetch, attempts 3, exponential backoff 5s).
- Step 1: Resolve org slug + Nango connection id exactly as
runtime-tool-executor.service.ts#executeSlackWorkspaceSearchdoes (stored connection id from the credential row;NangoService.httpProxy({ orgSlug, provider: 'slack_archive', method: 'GET', endpoint, connectionId, params })). - Step 2: Once per run:
users.listβMap<userId, displayName>(fallback: raw id). IfincludeFuture:conversations.list(types=public_channel&exclude_archived=true, paginate) β append unseen channels tochannelsjsonb. - Step 3: Per configured channel (own try/catch): page
conversations.historywitholdest = cursors[channelId] ?? slackTs(sinceDate),limit=200; hard cap 5000 messages per source per run (stop paging; cursor persists; next tick resumes). Skip subtypes (channel_join,bot_messagewithout text, etc.). - Step 4: Per message β envelope insert (NO scrubbing):
const line = `[${fmtUtc(msg.ts)}] ${authors.get(msg.user) ?? msg.user}: ${msg.text ?? ""}`;
await repo.createQueryBuilder().insert().into(ArchiveRecord).values({
orgId, sourceId, provider: "slack", docType: "message",
docExternalId: `${channelId}:${msg.ts}`,
rawData: line, sha: sha256(line),
meta: { channel_id: channelId, channel_name: channelName, author: authors.get(msg.user) ?? msg.user, ts: msg.ts },
}).orIgnore().execute(); // ON CONFLICT (org_id, provider, doc_external_id) DO NOTHING
- Step 5: For each touched
(channel, day): rebuild the day-doc from envelopes (ORDER BY meta->>'ts'), header + lines;hash = sha256(text); comparearchive_index_state:- unchanged β skip;
- new β
haystackClient.ingest({ org_id, dataset: "slack-archive", filename:${channelName}-${day}.txt, content_b64, mime_type: "text/plain", meta: <Locked contract meta> }), insert state row; - changed β evict previous doc first (use the same client eviction call
HaystackIngestionService/golden-answer-syncuse for evict-then-reindex β verify the exact method name inhaystack.client.tswhen implementing), then ingest + update state row (evict-then-reindex, P2.4 pattern).
- Step 6: Advance
cursors[channelId](max ts) ONLY after that channel's envelope + state writes persisted; setlast_synced_at. Specapi/test/slack-archive-fetch.spec.ts: envelope mapping + ON CONFLICT, exact day-doc format, unchanged-hash skips, changed-hash evicts-then-ingests, cursor-after-persist, 5000 cap. Run only it. Commit.
Task 5: Config API (owner: ArchivePipeline)β
Files: Create api/src/slack-archive/slack-archive-config.service.ts, slack-archive.controller.ts, slack-archive.module.ts.
- Step 1: Controller under
orgs/:orgId/slack-archive, guard pattern copied fromsources.controller.ts(client admin roles + platform superadmin/AM):GET /β config + status (enabled, channels, sinceDate, lastSyncedAt, lastError, per-channel cursor presence);PUT /β{ enabled, channels: [{id,name}], includeFuture, sinceDate }(validate shape; stampupdatedBy); creates the row on first call (defaultsinceDate= yesterday);GET /channelsβ liveconversations.listvia Nango proxy (feeds the picker UI later);POST /resyncβ resetcursors = {}, clearlastError(envelope dedup makes refetch idempotent).
- Step 2: Module: BullMQ queue +
forFeatureentities + Nango/scheduler/leader imports mirroringkb.module.ts. Do NOT touchapp.module.ts. - Step 3: Spec
api/test/slack-archive-config.spec.ts: PUT validation, role guard (client admin + AM allowed, member rejected), resync resets cursors. Run only it. Commit.
Task 6: Dataset + ArchiveRetrievalService (owner: ArchiveTool)β
Files: Modify api/src/haystack/haystack.types.ts; Create api/src/haystack/archive-retrieval.service.ts; Modify api/src/haystack/haystack.module.ts.
- Step 1: Widen
HaystackDatasetwith"slack-archive". VerifyresolveHaystackSourcestill defaults unknowns todefault-kb(KB draft path unreachable for the new dataset) andkb.service.tssearch source mapping never produces it. Check rag-side (rag/pipelines/*/pipeline_wrapper.py) passes arbitrary dataset strings; widen any trivial allowlist. - Step 2: Service β direct client, no org_documents ACL, fail-open:
async search(orgId: string, query: string, opts?: { topK?: number }) {
if (await this.pauseState.isIntegrationPausedSafe(orgId, "ragflow")) return { results: [] };
try {
const resp = await this.client.retrieve({
org_id: orgId, org_ids: [orgId], query,
source: "slack-archive", top_k: Math.min(Math.max(opts?.topK ?? 8, 1), 25),
specialist_id: null, specialist_ids: [], audiences: ["ai_internal"],
jurisdiction: null, as_of: new Date().toISOString().slice(0, 10),
});
return { results: resp.chunks.map(c => ({
channel: c.meta?.channel_name ?? c.meta?.channel_id ?? "unknown",
day: c.meta?.day ?? null,
snippet: (c.content ?? "").slice(0, 700),
score: c.score ?? 0,
})) };
} catch (err) { this.logger.warn(`archive search failed org=${orgId}: ${err}`); return { results: [] }; }
}
Docstring: isolation = org_id/org_ids filter over an org-scoped dataset; no KB governance semantics (status/effective-date) apply; deliberately no FORCE-RLS post-filter.
- Step 3: Provide + export from
haystack.module.ts. Specapi/test/archive-retrieval.service.spec.ts. Run only it. Commit.
Task 7: Tool registry + executor + flag (owner: ArchiveTool)β
Files: Modify tool-registry.ts, runtime-tool-executor.service.ts (+ its module imports), tool-resolution.util.ts, feature-flags.constants.ts, feature-flag.service.spec.ts, tool-dispatch-wiring.spec.ts.
- Step 1: Registry entry:
slack_archive_search: {
name: 'slack_archive_search', kind: 'bespoke', handler: 'slack_archive',
description: "Search the org's archived Slack workspace history (synced copy). Results are unvetted conversation history, NOT verified knowledge: attribute claims to their author and date; do not state them as confirmed fact.",
integrationTypes: ['slack_archive'],
actions: [{ name: 'search', description: 'Search archived Slack messages',
parameters: { type: 'object', required: ['query'], properties: {
query: { type: 'string', description: 'Search query over archived Slack messages' },
top_k: { type: 'integer', default: 8, maximum: 25 } } } }],
},
- Step 2: Executor: add handler branch
'slack_archive'βthis.archiveRetrieval.search(input.orgId, params.query, { topK: params.top_k }), returned in the sibling handlers'{ ok: true, ... }envelope. ImportHaystackModuleinto the runtime-control-plane module if absent. - Step 3: Flag:
slack_archive_enabledinfeature-flags.constants.ts(both touchpoints, default OFF, comment) +FLAG_GATED_TOOLS['slack_archive_search'] = 'slack_archive_enabled'+ update the flag-list spec. - Step 4: Extend
tool-dispatch-wiring.spec.tswith advertisement ON/OFF cases mirroringslack_search. Run only the touched specs. Commit.
Task 8: Integration (owner: main session)β
- Register
SlackArchiveModuleinapi/src/app.module.ts. - Verify both entity arrays contain the three entities; verify no file conflicts between the two workers.
-
cd api && npx tsc --noEmitβ zero errors.
Task 9: Verification (owner: main session)β
- Run all new spec files together:
cd api && npx jest slack-archive archive-retrieval tool-dispatch-wiring feature-flag.service --silentβ all pass. - Negative checks (the reason the design exists): grep confirms
api/src/slack-archive/never importsHaystackIngestionService/OrgDocument;resolveHaystackSourceunreachable forslack-archivefrom the draft path. - Update
api/CLAUDE.mdmodule index (+ 5-lineapi/src/slack-archive/CLAUDE.md). Commit.
Task 10: Ship checklist (config, not code)β
- Nango dashboard: create Slack OAuth app + integration key
slack_archive(user scopeschannels:read,channels:history,users:read; leave bot scopes empty). - Run migration on target env (
migrate.ymlworkflow). - Pilot client admin: Sources page β connect Slack History (Connect-session flow works as soon as the provider is registered).
-
PUT orgs/:orgId/slack-archiveβ{ enabled: true, channels: [...], sinceDate: <yesterday default> }(engineer/AM via API until the picker UI lands). - Publish pilot Specialist Skill with
slack_archive_searchintools[]+ read-class binding; flipslack_archive_enabled(+tool_dispatch_enabled) for the pilot org. - Smoke: wait one sync tick (or
POST /resync) β ask the Specialist a question answerable only from Slack history β draft cites archived content;ToolCallledger row present; flag-OFF org advertises nothing; KB tab shows no archive docs.
Explicitly deferred (named slots)β
Channel-picker drawer UI (config via API until then) Β· secret scrubbing (product decision to land raw; revisit before non-pilot orgs) Β· threads (conversations.replies) Β· private channels Β· GitHub/Notion providers (envelope already generic: provider/doc_type fields) Β· retention/erasure policy semantics (envelope table is the substrate) Β· budget metering via CostBudgetService (cap-per-run constant only in v0).