Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions starter/slack-diligence/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
PORT=3001
PUBLIC_BASE_URL=https://your-public-tunnel.example

# PUBLIC_BASE_URL is a setup convenience. Configure Slack Events API at:
# ${PUBLIC_BASE_URL}/api/tag/slack/webhook

# Slack ingress and PDF delivery. Keep these secret.
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=xoxb-...

# Inference: set one provider key. Research and draft share that provider.
ANTHROPIC_API_KEY=sk-...
# OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=
# GOOGLE_API_KEY=

# Public research.
EXA_API_KEY=
# Optional: enables fetch_page when Exa excerpts are insufficient.
FIRECRAWL_API_KEY=

# Optional inference overrides (same provider; model name only).
# INTX_PROVIDER=anthropic
# INTX_MODEL=claude-sonnet-4-6
# RESEARCH_MODEL=claude-sonnet-4-6
# DRAFT_MODEL=claude-opus-4-6
8 changes: 8 additions & 0 deletions starter/slack-diligence/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
tmp/
*.log
*.pdf
.env
.env.*
!.env.example
!.env.*.example
88 changes: 88 additions & 0 deletions starter/slack-diligence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# slack-diligence

A sourced diligence snapshot workflow driven from Slack through Corbits Tag:

```text
Slack mention or DM -> research -> draft -> Slack card + PDF
```

Send a company name and website. The research agent gathers public evidence with
Exa, the draft agent turns that into a sourced investment snapshot, and the bot
posts the card and a downloadable PDF in the same thread.

This is a deliberately small workflow adapted from Scout's diligence flow. It
does not include Scout's knowledge database, document ingestion, artifact
engine, portal, hub, or sidecar.

## Setup

1. Copy this directory, install, and create env:

cd slack-diligence
bun install
cp .env.example .env

Corbits Tag comes from `github:corbitsdev/corbits-tag` (already listed in
package.json). `bun install` is enough; this starter does not use git
submodules.

2. Expose port `3001` through an HTTPS tunnel. Use this Slack Events API
request URL:

```text
https://your-public-tunnel.example/api/tag/slack/webhook
```

3. Create a Slack app from `manifest.slack.json`, set that request URL, and
install the app in the workspace. The manifest includes `files:write` for
PDF delivery.

4. Populate `.env` with `SLACK_SIGNING_SECRET`, `SLACK_BOT_TOKEN`,
`EXA_API_KEY`, and one provider key: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
or `GOOGLE_API_KEY`. `FIRECRAWL_API_KEY` is optional; without it, research
still uses Exa excerpts but cannot open full pages. `RESEARCH_MODEL` and
`DRAFT_MODEL` only override the model name on that same provider.

5. Start the HTTP server:

```bash
bun run start
```

The server listens on:

```text
POST /api/tag/slack/webhook
```

## Use it

Mention the bot in a channel or send it a DM:

```text
@corbits-diligence Linear | https://linear.app
```

The bot posts a start card, runs `research -> draft`, posts the sourced
snapshot, and uploads a PDF in the same thread. Step context is written under
`tmp/slack-diligence/`. Delete that directory for a fresh start.

## Verify locally

bun run typecheck
bun test

## Files

| Path | Purpose |
| --- | --- |
| `src/cli.ts` | HTTP server and Corbits Tag mount |
| `src/session.ts` | Slack-thread lifecycle and workflow run state |
| `src/cards.ts` | Status and result cards as Slack mrkdwn |
| `src/workflow.ts` | `research -> draft` workflow and step invoker |
| `src/web-research.ts` | Exa search and optional Firecrawl page fetch |
| `src/parser.ts` | ArkType validation for snapshot JSON |
| `src/request.ts` | Company / website input parsing |
| `src/pdf.ts` | In-memory PDF render |
| `src/slack-upload.ts` | Slack `filesUploadV2` delivery |
| `src/source.ts` | Provider selection from environment variables |
437 changes: 437 additions & 0 deletions starter/slack-diligence/bun.lock

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions starter/slack-diligence/manifest.slack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"display_information": {
"name": "corbits-diligence",
"description": "Create a sourced company diligence snapshot",
"background_color": "#000000"
},
"features": {
"app_home": {
"home_tab_enabled": false,
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": false
},
"bot_user": {
"display_name": "corbits-diligence",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"chat:write",
"files:write",
"im:history",
"users:read",
"users:read.email"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://your-public-tunnel.example/api/tag/slack/webhook",
"bot_events": [
"app_mention",
"message.im"
]
},
"interactivity": {
"is_enabled": false
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false,
"token_rotation_enabled": false
}
}
35 changes: 35 additions & 0 deletions starter/slack-diligence/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@corbits/example-slack-diligence",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"start": "bun run src/cli.ts",
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@chat-adapter/state-memory": "^4.34.0",
"@intx/agent": "0.2.2",
"@intx/storage-isogit": "0.2.2",
"@intx/workflow": "0.2.2",
"@slack/web-api": "^7.19.0",
"arktype": "^2.1.29",
"chat": "^4.34.0",
"corbits-tag": "github:corbitsdev/corbits-tag",
"hono": "^4.12.31",
"pdfkit": "0.19.1"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/pdfkit": "0.17.6",
"typescript": "^5.9.3"
}
}
44 changes: 44 additions & 0 deletions starter/slack-diligence/src/cards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";

import { diligenceCard, statusCard } from "./cards";
import type { DiligenceBrief } from "./types";

const brief: DiligenceBrief = {
dealName: "Star*Corp",
company: "Star*Corp",
website: "https://star.example",
asOf: "2026-08-07T12:00:00.000Z",
summary: "Limited evidence.",
rationale: "Needs review.",
verdict: "Watch ~draft~ with *emphasis*",
nextAction: "Call the founder_now",
risks: ["Spend was ~$420K"],
claims: [],
sections: [],
};

describe("diligence cards", () => {
test("status card is Slack mrkdwn, not a Chat Card element", () => {
const card = statusCard("Diligence started", "Run `abc` is researching.");
expect(typeof card).toBe("string");
expect(card).toContain("*Diligence started*");
expect(card).toContain("Run `abc` is researching.");
expect(card).not.toContain("children");
});

test("escapes model markdown while keeping intentional bold wrappers", () => {
const card = diligenceCard(brief, { pdfAttached: true });
expect(typeof card).toBe("string");
expect(card).toContain("*Star\\*Corp*");
expect(card).toContain("_Diligence brief · 2026-08-07_");
expect(card).toContain("Watch \\~draft\\~ with \\*emphasis\\*");
expect(card).toContain("*Next action:* Call the founder\\_now");
expect(card).toContain("*Top risk:* Spend was \\~$420K");
expect(card).toContain("Full sourced brief attached as PDF.");
});

test("notes PDF delivery failure in the snapshot", () => {
const card = diligenceCard(brief, { pdfAttached: false });
expect(card).toContain("PDF delivery failed; Slack snapshot only.");
});
});
55 changes: 55 additions & 0 deletions starter/slack-diligence/src/cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { DiligenceBrief } from "./types";

const MAX_CARD_TITLE_LENGTH = 150;
const MAX_CARD_BODY_LENGTH = 2_700;

export function statusCard(title: string, text: string): string {
return `*${clampTitle(title)}*\n\n${escapeGeneratedMrkdwn(truncateBody(text))}`;
}

export function diligenceCard(
brief: DiligenceBrief,
opts: { pdfAttached: boolean },
): string {
const lines = [
escapeGeneratedMrkdwn(brief.verdict),
...(brief.nextAction === undefined
? []
: [`*Next action:* ${escapeGeneratedMrkdwn(brief.nextAction)}`]),
...(brief.risks?.[0] === undefined
? []
: [`*Top risk:* ${escapeGeneratedMrkdwn(brief.risks[0])}`]),
...(opts.pdfAttached
? ["Full sourced brief attached as PDF."]
: ["PDF delivery failed; Slack snapshot only."]),
];

return [
`*${clampTitle(escapeGeneratedMrkdwn(brief.company))}*`,
`_Diligence brief · ${escapeGeneratedMrkdwn(brief.asOf.slice(0, 10))}_`,
"",
truncateBody(lines.join("\n")),
].join("\n");
}

function clampTitle(text: string): string {
return text.length > MAX_CARD_TITLE_LENGTH
? `${text.slice(0, MAX_CARD_TITLE_LENGTH - 1)}…`
: text;
}

function truncateBody(text: string): string {
return text.length > MAX_CARD_BODY_LENGTH
? `${text.slice(0, MAX_CARD_BODY_LENGTH - 1)}…`
: text;
}

function escapeGeneratedMrkdwn(text: string): string {
// TagThread posts these strings as Slack mrkdwn. Escape model text at the
// leaf so intentional *bold* wrappers around labels still work.
return text
.replaceAll("\\", "\\\\")
.replaceAll("~", "\\~")
.replaceAll("*", "\\*")
.replaceAll("_", "\\_");
}
69 changes: 69 additions & 0 deletions starter/slack-diligence/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createMemoryState } from "@chat-adapter/state-memory";
import { mountSlackTag } from "corbits-tag/slack";
import { Hono } from "hono";

import { resolveConfig, SERVICE_NAME } from "./config";
import { createDiligenceSessions } from "./session";

export type MainOptions = {
stdout?: (text: string) => void;
stderr?: (text: string) => void;
contextRoot?: string;
};

export async function main(
argv: string[],
env: NodeJS.ProcessEnv,
opts: MainOptions = {},
): Promise<number> {
const stdout =
opts.stdout ?? ((text: string) => void process.stdout.write(text));
const stderr =
opts.stderr ?? ((text: string) => void process.stderr.write(text));

if (argv.includes("--help") || argv.includes("-h")) {
stdout("usage: bun run start\n\nStart the Slack diligence workflow.\n");
return 0;
}

const resolved = resolveConfig(env, opts.contextRoot);
if (resolved.error !== undefined) {
stderr(resolved.error);
return 1;
}

const sessions = createDiligenceSessions(resolved.config, stderr);
const app = new Hono();
const mounted = mountSlackTag(app, {
userName: "corbits-diligence",
state: createMemoryState(),
slack: {
botToken: resolved.config.botToken,
signingSecret: resolved.config.signingSecret,
},
acknowledge: true,
thinkingIndicator: true,
thinkingIndicatorText: "_Preparing diligence snapshot…_",
subscribeOnMention: false,
onTag: async (event, thread) => {
await sessions.start(event, thread);
},
});

try {
Bun.serve({ port: resolved.config.port, fetch: app.fetch });
} catch (cause) {
stderr(`${cause instanceof Error ? cause.message : String(cause)}\n`);
return 1;
}

stdout(
`${SERVICE_NAME} listening on http://localhost:${resolved.config.port}${mounted.path}\n`,
);
return await new Promise<never>(() => undefined);
}

if (import.meta.main) {
const code = await main(process.argv.slice(2), process.env);
if (code !== 0) process.exit(code);
}
Loading