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. Specapi/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 andconfigured:falsearm. 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 oninput.action:searchβ existing path;getβarchiveRead.getDayDoc(orgId, params.channel, params.day)(missing params β the sibling 400 envelope);statusβarchiveRead.status(orgId). InjectArchiveReadService@Optional()appended last; runtime-control-plane module importsSlackArchiveModule(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) thatexpandToNameActionadvertises 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 inlast_erroras 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 validatesdatasetvia pydanticLiterals, so rag deploys BEFORE api for any dataset addition; on Railway dev, rag ships viarailway 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: documentget/statusactions + 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: widenSourceInfo["provider"]union with"slack_archive"; add typed fnsgetSlackArchiveConfig(orgId),putSlackArchiveConfig(orgId, body),listSlackArchiveChannels(orgId),resyncSlackArchive(orgId)againstorgs/: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 β reuseREAD_ONLY_TOASTgating) 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) /Everythingwith inline warning "large workspaces may take hours and increase processing cost". Maps tosinceDate. - 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 --noEmitscoped per frontend conventions. Commit.
Task 7: Integration + delivery (owner: main session)β
- Combined spec run (backend suites + frontend test),
tscboth 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.