Skip to content
Open
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
98 changes: 98 additions & 0 deletions starter/slack-diligence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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.

This starter builds on the Corbits Tag dependency introduced by the Slack agent
starter. It uses npm `@intx/*` packages at `0.2.2` and consumes the shared,
pinned Corbits Tag checkout at `../slack-agent/vendor/corbits-tag` as a Bun
workspace. It does not register or clone a second submodule.

## Setup

1. Clone the repository with its submodules and install this starter:

```bash
git clone --recurse-submodules https://github.com/corbitsdev/examples.git
cd examples/starter/slack-diligence
bun install
cp .env.example .env
```

For an existing clone, initialize the shared Corbits Tag submodule from the
repository root:

```bash
git submodule update --init --recursive starter/slack-agent/vendor/corbits-tag
```

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.

Run `bun run typecheck` for local verification. Run `bun test` for parser,
request, and web-research regressions.

## 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` | Chat SDK status and result cards |
| `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 |
| `../slack-agent/vendor/corbits-tag` | Shared pinned Corbits Tag workspace |
463 changes: 463 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
}
}
38 changes: 38 additions & 0 deletions starter/slack-diligence/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@corbits/example-slack-diligence",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"workspaces": [
"../slack-agent/vendor/corbits-tag/packages/*"
],
"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",
"@corbits/tag-slack": "workspace:*",
"@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",
"hono": "^4.12.31",
"pdfkit": "0.19.1"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/pdfkit": "0.17.6",
"typescript": "^5.9.3"
}
}
39 changes: 39 additions & 0 deletions starter/slack-diligence/src/cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Card, CardText, type CardElement } from "chat";

import type { DiligenceBrief } from "./types";

const MAX_TEXT_LENGTH = 2_900;

export function statusCard(title: string, text: string): CardElement {
return Card({ title, children: [CardText(truncate(text))] });
}

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

return Card({
title: brief.company,
subtitle: `Diligence brief · ${brief.asOf.slice(0, 10)}`,
children: [CardText(truncate(lines.join("\n")))],
});
}

function truncate(text: string): string {
return text.length > MAX_TEXT_LENGTH
? `${text.slice(0, MAX_TEXT_LENGTH - 3)}...`
: text;
}
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 { Chat } from "chat";
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,
},
subscribeOnMention: false,
onTag: (event) => sessions.start(event, chat.thread(event.threadId)),
});
if (!(mounted.bot instanceof Chat)) {
throw new Error("mountSlackTag did not return its Chat SDK bot");
}
const chat = mounted.bot;

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