Skip to main content

Slack Archive: get/status Tools + Config UI 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: Give the Specialist full day-doc retrieval (get) and archive freshness visibility (status) on the existing slack_archive_search tool; fix the [object Object] error surfacing; document the rag-before-api deploy rule; ship the client-facing channel-picker drawer on the Sources page.

Architecture: get/status are new actions on the existing tool β€” the skill∩binding intersection expands per-action automatically (expandToNameAction), so no new binding/skill/permission is needed. get rebuilds the day-doc from envelopes (archive_records β€” the source of truth), not chunks, capped at 15k chars keeping the newest tail. status reads config + index bookkeeping. Day-doc reads live in a new ArchiveReadService inside the slack-archive module (it owns the repos); search stays in ArchiveRetrievalService. Frontend: the Sources tile gains icon/hint + a post-connect Configure drawer (checklist + from-date tiers + resync) talking to the four existing endpoints.

Tech Stack: NestJS 11, existing slack-archive module, tool gateway registry, Next.js 16 Sources page (frontend/src/app/client/settings/sources/page.tsx), existing lib/api.ts client.

Branch: feat/slack-archive-tools off origin/dev.

Decisions locked (from product review): get = full day-doc capped ~15k chars with truncation marker (newest kept) Β· distillation loop deferred, posture decided: KB via existing upload path, pending_review Β· retention checkbox NOT in this drawer (no retention column in PoC schema β€” arrives with the policy work) Β· secret scrubbing still deferred (pre-non-pilot gate).


File Structure​

api/src/slack-archive/archive-read.service.ts (create: getDayDoc + status)
api/src/slack-archive/slack-archive.module.ts (modify: provide+export ArchiveReadService)
api/src/slack-archive/slack-archive-fetch.processor.ts (modify: error formatting util + use)
api/src/agent-api/tool-registry.ts (modify: get + status actions)
api/src/runtime-control-plane/runtime-tool-executor.service.ts (modify: action dispatch for slack_archive)
api/src/slack-archive/CLAUDE.md (modify: new actions)
docs/ops/deploy.md (modify: rag-before-api rule)
api/test/archive-read.service.spec.ts (create)
api/test/runtime-tool-executor-slack-archive.spec.ts (modify: get/status dispatch)
api/test/slack-archive-fetch.spec.ts (modify: error-format regression)
frontend/src/lib/api.ts (modify: SourceInfo union + 4 archive fns)
frontend/src/app/client/settings/sources/page.tsx (modify: tile icon/hint + Configure entry)
frontend/src/components/client/sources/SlackArchiveDrawer.tsx (create)
frontend/src/components/client/sources/__tests__/SlackArchiveDrawer.test.tsx (create)

Task 1: ArchiveReadService β€” getDayDoc + status (owner: backend worker)​

Files: Create api/src/slack-archive/archive-read.service.ts; Modify slack-archive.module.ts (provide + export).

  • Step 1: Service with two methods, repos injected (ArchiveRecord, ArchiveIndexState, SlackArchiveSource):
const DAY_DOC_CAP = 15_000;

/** Full day-doc rebuilt from envelopes (source of truth), newest tail kept under cap. */
async getDayDoc(orgId: string, channel: string, day: string): Promise<{ found: boolean; channel?: string; day?: string; truncated?: boolean; omitted_messages?: number; text?: string }> {
// channel accepts id (C…) or name; day must be YYYY-MM-DD (validate, else found:false)
const rows = await this.recordRepo.createQueryBuilder("r")
.where("r.org_id = :orgId", { orgId })
.andWhere("(r.meta->>'channel_id' = :ch OR r.meta->>'channel_name' = :ch)", { ch: channel.replace(/^#/, "") })
.andWhere("substring(r.raw_data, 2, 10) = :day", { day }) // raw_data starts "[YYYY-MM-DD HH:MM] …"
.orderBy("r.meta->>'ts'", "ASC").getMany();
if (!rows.length) return { found: false };
const name = rows[0].meta.channel_name ?? channel;
const header = `#${name} β€” ${day}`;
let lines = rows.map(r => r.rawData); let omitted = 0;
// Keep the NEWEST tail under the cap; marker replaces the dropped head.
while (omitted < lines.length && header.length + lines.slice(omitted).join("\n").length + 80 > DAY_DOC_CAP) omitted++;
const body = lines.slice(omitted).join("\n");
const marker = omitted ? `[truncated: ${omitted} earlier message(s) omitted]\n` : "";
return { found: true, channel: name, day, truncated: omitted > 0, omitted_messages: omitted, text: `${header}\n${marker}${body}` };
}

/** Freshness/coverage β€” the Observe primitive. All fields cheap (no Slack calls). */
async status(orgId: string): Promise<Record<string, unknown>> {
const src = await this.sourceRepo.findOne({ where: { orgId } });
if (!src) return { configured: false };
const days = await this.indexRepo.createQueryBuilder("i")
.select("i.channel_id", "channel_id").addSelect("count(*)", "days").addSelect("max(i.day)", "latest_day")
.where("i.org_id = :orgId", { orgId }).groupBy("i.channel_id").getRawMany();
return {
configured: true, enabled: src.enabled,
channels: src.channels.map(c => ({ name: c.name, indexed_days: Number(days.find(d => d.channel_id === c.id)?.days ?? 0), latest_day: days.find(d => d.channel_id === c.id)?.latest_day ?? null })),
since_date: src.sinceDate, last_synced_at: src.lastSyncedAt, last_error: src.lastError,
};
}

ADR-020: both methods filter org_id on every query; docstring notes results are unvetted history served only through the governed tool.

  • Step 2: Provide + export from slack-archive.module.ts. Spec api/test/archive-read.service.spec.ts: rebuild order (by ts), id-vs-name channel match, # prefix tolerated, cap keeps newest + marker + omitted count exact, unknown channel/day β†’ {found:false}, bad day format β†’ {found:false} (never throws), status shape incl. per-channel indexed_days and configured:false arm. Run only it. Commit.

Task 2: Registry actions + executor dispatch (owner: backend worker)​

Files: Modify tool-registry.ts, runtime-tool-executor.service.ts, extend api/test/runtime-tool-executor-slack-archive.spec.ts.

  • Step 1: Add to slack_archive_search.actions (search action unchanged):
{ name: 'get', description: 'Fetch the full archived transcript of one channel for one day (use after search to read complete context). Content is unvetted conversation history β€” attribute claims to author and date.',
parameters: { type: 'object', required: ['channel', 'day'], properties: {
channel: { type: 'string', description: "Channel name (with or without #) or ID" },
day: { type: 'string', description: 'YYYY-MM-DD' } } } },
{ name: 'status', description: 'Archive freshness and coverage: which channels are synced, through when, and any sync errors.',
parameters: { type: 'object', properties: {} } },
  • Step 2: Executor: the 'slack_archive' handler branches on input.action: search β†’ existing path; get β†’ archiveRead.getDayDoc(orgId, params.channel, params.day) (missing params β†’ the sibling 400 envelope); status β†’ archiveRead.status(orgId). Inject ArchiveReadService @Optional() appended last; runtime-control-plane module imports SlackArchiveModule (verify no cycle β€” slack-archive must not import runtime-control-plane; it doesn't).
  • Step 3: Specs: dispatch per action, get missing-params 400, service-absent 400, envelopes match sibling {ok:true,...} shape. Verify (state in the report) that expandToNameAction advertises all three actions from the existing binding β€” extend the tool-dispatch-wiring expectation from ["slack_archive_search:search"] to all three colon-pairs. Run only touched specs. Commit.

Task 3: Error formatting fix (owner: backend worker)​

Files: Modify slack-archive-fetch.processor.ts + api/test/slack-archive-fetch.spec.ts.

  • Step 1: Add and use everywhere a channel/source error is stringified (the [object Object] live bug):
function errText(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
try { return JSON.stringify(err).slice(0, 300); } catch { return String(err); }
}
  • Step 2: Regression spec: a thrown plain object (e.g. {status:"error", http_status:422}) surfaces in last_error as readable JSON, never [object Object]. Run only slack-archive suites. Commit.

Task 4: Deploy runbook note + module doc (owner: backend worker)​

  • docs/ops/deploy.md: add a "Dataset/schema changes spanning api + rag" note: rag validates dataset via pydantic Literals, so rag deploys BEFORE api for any dataset addition; on Railway dev, rag ships via railway up --service humanwork-rag (no auto-deploy β€” this exact gap caused the first live sync tick to fail on 2026-07-28). api/src/slack-archive/CLAUDE.md: document get/status actions + the errText rule. Commit.

Task 5: Frontend β€” tile polish + API client (owner: frontend worker)​

Files: Modify frontend/src/lib/api.ts, frontend/src/app/client/settings/sources/page.tsx.

  • Step 1: lib/api.ts: widen SourceInfo["provider"] union with "slack_archive"; add typed fns getSlackArchiveConfig(orgId), putSlackArchiveConfig(orgId, body), listSlackArchiveChannels(orgId), resyncSlackArchive(orgId) against orgs/:orgId/slack-archive[...], following the file's existing fetch-wrapper idiom exactly.
  • Step 2: Sources page: SOURCE_ICONS.slack_archive = { imgIcon: "/icons/slack.png" }; SOURCE_HINTS.slack_archive = "Keeps a searchable archive of selected public channels so your Specialist can answer questions about past team discussions."; on the connected slack_archive tile render a Configure button (admins only β€” reuse READ_ONLY_TOAST gating) opening the drawer. Commit.

Task 6: SlackArchiveDrawer (owner: frontend worker)​

Files: Create frontend/src/components/client/sources/SlackArchiveDrawer.tsx + test.

  • Step 1: Drawer (follow the repo's existing drawer/modal idiom β€” find one on the settings pages and mirror it):
    • Loads config + channels in parallel on open; searchable checklist (checkbox per channel, selected count line), channels sorted selected-first then alphabetically.
    • Include future channels toggle.
    • Import history from radio tiers: Yesterday (default) / Last 30 days / Last 90 days / Custom date… (native date input) / Everything with inline warning "large workspaces may take hours and increase processing cost". Maps to sinceDate.
    • Enabled toggle; footer: Save (PUT, optimistic close + toast) and β€” when already enabled β€” Resync with confirm ("Refetches from the start date; existing archive entries are kept, duplicates are ignored").
    • Status line at top when configured: Last synced <relative time> Β· <n> channels Β· <lastError if any, styled as warning>.
  • Step 2: Component test (mirror an existing client-component test): renders channels from mock, save sends exact PUT body, member role sees read-only, tierβ†’sinceDate mapping incl. custom date. Run only this test file + npx tsc --noEmit scoped per frontend conventions. Commit.

Task 7: Integration + delivery (owner: main session)​

  • Combined spec run (backend suites + frontend test), tsc both sides, CLAUDE.md check.
  • PR to dev; Greptile iteration to zero findings; merge.
  • Deploy order: rag unchanged this round β€” api only (note in PR body); after dev deploy, smoke: ask Hiroshi "Show me the full human-work discussion from yesterday" β†’ expect ToolCall action='get'; ask "How fresh is your Slack archive?" β†’ action='status'; drawer round-trip on the Sources page as percy+client24072026.

Explicitly deferred (unchanged slots)​

Secret scrubbing (gate before non-pilot orgs) Β· distillation loop (posture decided: llm_distill per channel-day β†’ existing KB upload path, pending_review) Β· multi_get day ranges Β· retention checkbox (needs policy-round schema) Β· threads/private channels Β· GitHub/Notion providers.