diff --git a/.github/workflows/thor-example-ci.yml b/.github/workflows/thor-example-ci.yml new file mode 100644 index 0000000..3e6fa9c --- /dev/null +++ b/.github/workflows/thor-example-ci.yml @@ -0,0 +1,27 @@ +name: Thor example CI + +on: + push: + branches: [main] + paths: + - "examples/thor-slack-agent/**" + - ".github/workflows/thor-example-ci.yml" + pull_request: + paths: + - "examples/thor-slack-agent/**" + - ".github/workflows/thor-example-ci.yml" + +jobs: + typecheck: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/thor-slack-agent + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm run typecheck + - run: npm run build diff --git a/examples/README.md b/examples/README.md index 463e078..aed9e29 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,6 +39,12 @@ PORT=3001 npx @taskade/mcp-server --http --token YOUR_TASKADE_API_KEY Then configure the n8n MCP node to use **HTTP Streamable** transport with URL `http://localhost:3001/mcp`. +## Thor — Slack AI agent + +[Thor](./thor-slack-agent) is a Slack AI agent powered by the Taskade MCP server. Mention `@Thor` in a channel or DM him, and he reasons with Claude and acts on your Taskade workspace — creating projects, capturing tasks, and organizing work without leaving Slack. + +See [`./thor-slack-agent`](./thor-slack-agent) for setup and the Slack app manifest. + ## More examples coming soon - CrewAI integration diff --git a/examples/flipreseller-automations/OPERATIONS.md b/examples/flipreseller-automations/OPERATIONS.md new file mode 100644 index 0000000..6b1995d --- /dev/null +++ b/examples/flipreseller-automations/OPERATIONS.md @@ -0,0 +1,40 @@ +# FlipReseller — Operations Runbook + +How the pieces fit and how to run day-to-day. This is the human index for the automation blueprints in this folder. + +## The system at a glance + +- **Thor** (Slack agent, `../thor-slack-agent`) — mention him in a thread to turn conversation into Taskade projects/tasks. +- **Taskade** — Thor's tool belt (projects, tasks, agents). +- **Notion — FlipReseller** — the source of truth: Inventory, Tasks, Sources, Sales Log (schema in `notion-schema.md`). +- **Todoist — FlipReseller** — quick/mobile capture; syncs to Notion via Make. +- **Make.com** — the glue (scenarios in `make-scenarios.md`); Zapier backs up single hops (`zapier-recipes.md`). +- **GitHub → Slack** — dev notifications (`github-setup.md`). + +## Channel map (live) + +| Purpose | Channel | +| --- | --- | +| Sourcing intake (`!source …`) | `#flipreseller-operations` | +| Sold notifications | `#sales-agent` | +| Daily digest | `#flipreseller` | +| GitHub activity | `#flipreseller-dev` | + +## Capture conventions + +- **Log a find:** in `#flipreseller-operations`, post + `!source | cost | platform ` + → Make creates an Inventory row (Status = Sourced) and a Todoist "List " task. +- **Turn a thread into work:** `@Thor capture this thread into a Taskade project`. +- **Mark sold:** set an Inventory item's Status = Sold in Notion → Make logs the sale and posts to `#sales-agent`. + +## Daily / weekly cadence (mirrored in Todoist) + +- **Daily:** flip review — listings, orders, buyer messages. +- **Weekly:** sourcing run; P&L reconciliation (cost, fees, shipping, net). + +## Status & what's left + +- Done: Thor agent, Todoist project, Notion HQ page, automation blueprints. +- Pending — **Notion databases:** create the four under FlipReseller HQ (blocked only on the Notion connector being live). +- Pending — **Make scenarios:** connect Slack/Notion/Todoist in Make → *Connections*, free a scenario slot (Free plan = 2 active), then build A–D from `make-scenarios.md`. diff --git a/examples/flipreseller-automations/README.md b/examples/flipreseller-automations/README.md new file mode 100644 index 0000000..4eaa5d5 --- /dev/null +++ b/examples/flipreseller-automations/README.md @@ -0,0 +1,47 @@ +# ⚡ FlipReseller Automations + +The automation layer that wires your reselling stack together — **Slack**, **Taskade** (via Thor), **Notion** (FlipReseller workspace), **Todoist**, **GitHub**, glued with **Make.com** (primary) and **Zapier** (backup). + +## The stack, by job + +| Layer | Tool | Role | +| --- | --- | --- | +| Conversation | Slack | Where the team works; where Thor lives | +| Agents | Thor + Claude | Turn conversation into structured action | +| Projects/Tasks | Taskade | Thor's native tool belt | +| Source of truth | Notion (FlipReseller) | Inventory, sales, sources, tasks | +| Personal tasks | Todoist | Quick capture / on-the-go | +| Code | GitHub | This repo + notifications | +| Glue | Make.com / Zapier | Move data between the above | + +## Data flow (the big picture) + +``` +Slack thread ──@Thor──▶ Taskade project/tasks + │ │ + │ (!source keyword) │ (Make: Todoist/Taskade → Notion) + ▼ ▼ +Make scenario ─────────▶ Notion: Inventory + Tasks (FlipReseller) + ▲ │ + │ │ (Status = Sold) + Todoist ◀── list task ▼ + Notion: Sales Log ──▶ Slack #sales-agent 🎉 +``` + +## Build order + +1. **Notion** — create the FlipReseller workspace and databases → [`notion-schema.md`](./notion-schema.md). +2. **Todoist** — create a `FlipReseller` project (one line, in the app). +3. **Make.com** — connect Slack, Notion, Todoist, then build the scenarios → [`make-scenarios.md`](./make-scenarios.md). +4. **Zapier** — optional single-step backups → [`zapier-recipes.md`](./zapier-recipes.md). +5. **GitHub** — repo notifications into Slack → [`github-setup.md`](./github-setup.md). + +> Channel mapping in use: sourcing → #flipreseller-operations, sold → #sales-agent, digest → #flipreseller, GitHub → #flipreseller-dev. + +## Free-tier budget (keep it $0) + +- **Make free:** 1,000 ops/mo, 15-min min interval. Put the daily digest on a schedule; keep instant triggers for low-volume events (sales, sourcing). +- **Zapier free:** 100 tasks/mo, single-step. Use only for 1–2 high-value one-hop zaps. +- **Notion / Todoist / GitHub free:** unlimited for this usage. + +Rule of thumb: **Make for anything multi-step or branching; Zapier only when Make would burn ops on a trivial one-hop.** diff --git a/examples/flipreseller-automations/github-setup.md b/examples/flipreseller-automations/github-setup.md new file mode 100644 index 0000000..4ea65cd --- /dev/null +++ b/examples/flipreseller-automations/github-setup.md @@ -0,0 +1,16 @@ +# FlipReseller — GitHub → Slack + +## 1. Native notifications (no code) +In your Slack channel (e.g. #flipreseller-dev): +``` +/github subscribe samuelfreemanjobs-hash/mcp +``` +Tune the event types: +``` +/github subscribe samuelfreemanjobs-hash/mcp pulls issues commits releases +/github unsubscribe samuelfreemanjobs-hash/mcp deployments +``` +Link your account for richer previews: `/github signin`. + +## 2. Optional — release ping via Actions +For a custom Slack message on release, add a workflow that posts to a Slack Incoming Webhook. Store the webhook URL as a repo secret `SLACK_WEBHOOK_URL` and post with a `curl` step on the `release: published` event. (Kept out of this repo by default so the examples need no secrets to run.) diff --git a/examples/flipreseller-automations/make-scenarios.md b/examples/flipreseller-automations/make-scenarios.md new file mode 100644 index 0000000..86c7e98 --- /dev/null +++ b/examples/flipreseller-automations/make-scenarios.md @@ -0,0 +1,42 @@ +# FlipReseller — Make.com scenarios + +> ⚠️ Heads-up on your current Make org: it's on the **Free plan (max 2 active scenarios)** and already has 2, and none of Slack/Notion/Todoist are connected under *Connections* yet. Before these run, connect those three apps and free up a scenario slot (or upgrade). + +Primary automation layer. First, in Make → *Connections*, connect **Slack**, **Notion**, and **Todoist** (OAuth). Then build these scenarios. Each lists its modules top-to-bottom; when a step maps a Notion property, you pick the database once and Make surfaces its fields. + +## Scenario A — Todoist task → Notion Task +Keep every to-do in one place. + +1. **Todoist ▸ Watch Tasks** — trigger; project = `FlipReseller`. +2. **Notion ▸ Create a Database Item** — database = *Tasks*. + - Task = `{{Todoist content}}` + - Due = `{{Todoist due date}}` + - Origin = `Todoist` + - Status = `To do` + +Schedule: every 15 min (free-tier minimum). ~1 op/run. + +## Scenario B — Slack sourcing intake → Notion Inventory + Todoist +Post `!source | cost | platform ` in **#flipreseller-operations** and it's captured. + +1. **Slack ▸ Watch Public Channel Messages** — channel = `#flipreseller-operations`. +2. **Filter** — continue only if text starts with `!source`. +3. **Set variables / parse** — split on `|` to get item, cost, platform. +4. **Notion ▸ Create a Database Item** — database = *Inventory*: Item = parsed item, Status = `Sourced`, Cost = parsed cost, Platform = parsed platform. +5. **Todoist ▸ Create a Task** — content = `List {{item}}`, project = `FlipReseller`, due = `today +2 days`. +6. **Slack ▸ Create a Message** — channel `#flipreseller-operations`, threaded on the trigger message: "📦 Logged _{{item}}_ to Inventory and queued a listing task." + +## Scenario C — Item sold → Sales Log + celebrate +1. **Notion ▸ Watch Database Items** — database = *Inventory*, watch updated, filter `Status = Sold`. +2. **Notion ▸ Create a Database Item** — database = *Sales Log*: map Item (relation), Platform, Sale Price, Fees, Shipping, Net = `{{Net Profit}}`, Sold On = now. +3. **Slack ▸ Create a Message** — channel `#sales-agent`: "🎉 SOLD: *{{Item}}* for ${{Sale Price}} (net ${{Net Profit}}) on {{Platform}}." + +## Scenario D — Daily digest +1. **Schedule** — daily 08:00. +2. **Notion ▸ Search Database Items** — *Inventory*, filter `Status = Listed` (aging stock). +3. **Notion ▸ Search Database Items** — *Tasks*, filter `Due ≤ today` and `Status ≠ Done`. +4. **Array aggregator → Text** — build a summary. +5. **Slack ▸ Create a Message** — channel `#flipreseller`: today's open tasks + items live > 14 days. + +## Op budget +A + C + D at low volume, plus occasional B, stays well under Make's 1,000 ops/mo free tier. If Scenario A's 15-min polling gets heavy, move Todoist capture to Zapier (see `zapier-recipes.md`) to save Make ops. diff --git a/examples/flipreseller-automations/notion-schema.md b/examples/flipreseller-automations/notion-schema.md new file mode 100644 index 0000000..6dff6a6 --- /dev/null +++ b/examples/flipreseller-automations/notion-schema.md @@ -0,0 +1,73 @@ +# FlipReseller — Notion schema + +Create a Notion workspace named **FlipReseller** (Notion → workspace switcher → *Create workspace*; the API cannot create workspaces). Inside it, create one parent page **FlipReseller HQ** and add these four full-page databases. + +## 1. Inventory +The heart of the system — one row per item you source. + +| Property | Type | Notes | +| --- | --- | --- | +| Item | Title | e.g. "Nike Air Max 90 — sz 10" | +| Status | Select | Sourced · Listed · Sold · Shipped · Returned | +| Platform | Select | eBay · Poshmark · Mercari · FB Marketplace | +| SKU | Text | your internal code | +| Cost | Number ($) | what you paid | +| List Price | Number ($) | asking price | +| Sale Price | Number ($) | actual | +| Fees | Number ($) | platform + payment fees | +| Shipping | Number ($) | your cost to ship | +| Net Profit | Formula | see below | +| Source | Relation → Sources | where it came from | +| Tasks | Relation → Tasks | to-dos for this item | +| Listing URL | URL | live listing | +| Photos | Files | | +| Date Sourced | Date | | +| Date Sold | Date | | + +**Net Profit** formula: +``` +prop("Sale Price") - prop("Cost") - prop("Fees") - prop("Shipping") +``` + +## 2. Tasks +Operational to-dos, fed by Thor/Taskade, Todoist, and Slack. + +| Property | Type | Notes | +| --- | --- | --- | +| Task | Title | | +| Status | Status | To do · Doing · Done | +| Priority | Select | Low · Med · High | +| Due | Date | | +| Item | Relation → Inventory | optional | +| Origin | Select | Slack · Taskade · Todoist · Manual | + +## 3. Sources +Where inventory comes from. + +| Property | Type | +| --- | --- | +| Name | Title | +| Type | Select (Thrift · Estate · Wholesale · Online · Consignment) | +| Location | Text | +| Reliability | Select (A · B · C) | +| Notes | Text | + +## 4. Sales Log +One row per completed sale (created automatically when an item flips to Sold). + +| Property | Type | +| --- | --- | +| Item | Relation → Inventory | +| Platform | Select | +| Sale Price | Number ($) | +| Fees | Number ($) | +| Shipping | Number ($) | +| Net | Number ($) | +| Sold On | Date | + +## Dashboards (optional) +On **FlipReseller HQ**, add linked views: +- Inventory board grouped by Status +- Inventory filtered `Status = Listed` (what's live) +- Sales Log this month with a Net sum +- Tasks filtered `Due ≤ today` diff --git a/examples/flipreseller-automations/zapier-recipes.md b/examples/flipreseller-automations/zapier-recipes.md new file mode 100644 index 0000000..f5b1103 --- /dev/null +++ b/examples/flipreseller-automations/zapier-recipes.md @@ -0,0 +1,17 @@ +# FlipReseller — Zapier recipes (free-tier backups) + +Zapier free = 100 tasks/mo, single-step. Use it only where a one-hop zap saves Make ops. Build each at zapier.com → *Create Zap*. + +## Zap 1 — New Todoist task → Notion Task +- **Trigger:** Todoist ▸ New Task (project `FlipReseller`) +- **Action:** Notion ▸ Create Database Item (*Tasks*): Task = task content, Origin = Todoist. + +Use this INSTEAD of Make Scenario A if you'd rather save Make ops. + +## Zap 2 — Notion sold item → Slack +- **Trigger:** Notion ▸ Updated Database Item (*Inventory*, Status = Sold) +- **Action:** Slack ▸ Send Channel Message (#sales-agent): "🎉 Sold {{Item}} for ${{Sale Price}}". + +Single-step alternative to Make Scenario C's Slack step. + +> Don't run the Make and Zapier versions of the same flow at once — pick one per flow to avoid duplicates and wasted quota. diff --git a/examples/thor-slack-agent/.dockerignore b/examples/thor-slack-agent/.dockerignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/examples/thor-slack-agent/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/examples/thor-slack-agent/.env.example b/examples/thor-slack-agent/.env.example new file mode 100644 index 0000000..808b69e --- /dev/null +++ b/examples/thor-slack-agent/.env.example @@ -0,0 +1,19 @@ +# Anthropic API key — powers Thor's reasoning +ANTHROPIC_API_KEY= + +# Model Thor thinks with (defaults to a current Claude model) +THOR_MODEL=claude-sonnet-5 + +# Taskade API key — the tools Thor wields (get one at https://www.taskade.com/settings/password) +TASKADE_API_KEY= + +# Slack credentials (Socket Mode — no public URL required) +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... +SLACK_SIGNING_SECRET= + +# Optional tuning +# THOR_MAX_STEPS=12 +# Optional: command used to launch the Taskade MCP server (defaults shown) +# TASKADE_MCP_COMMAND=npx +# TASKADE_MCP_ARGS=-y,@taskade/mcp-server diff --git a/examples/thor-slack-agent/.gitignore b/examples/thor-slack-agent/.gitignore new file mode 100644 index 0000000..b0cfafd --- /dev/null +++ b/examples/thor-slack-agent/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +package-lock.json diff --git a/examples/thor-slack-agent/Dockerfile b/examples/thor-slack-agent/Dockerfile new file mode 100644 index 0000000..f4cf69f --- /dev/null +++ b/examples/thor-slack-agent/Dockerfile @@ -0,0 +1,21 @@ +# Thor — Slack AI agent (Socket Mode: dials out, no inbound ports) +FROM node:20-slim + +WORKDIR /app + +# Install deps first for better layer caching. +COPY package.json ./ +RUN npm install + +# Build. +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Pre-install the Taskade MCP server so Thor doesn't fetch it on each cold start. +RUN npm install -g @taskade/mcp-server +ENV TASKADE_MCP_COMMAND=taskade-mcp-server +ENV TASKADE_MCP_ARGS= + +# Socket Mode means Thor connects OUT to Slack, so no port is exposed. +CMD ["node", "dist/index.js"] diff --git a/examples/thor-slack-agent/README.md b/examples/thor-slack-agent/README.md new file mode 100644 index 0000000..8417320 --- /dev/null +++ b/examples/thor-slack-agent/README.md @@ -0,0 +1,121 @@ +# ⚡ Thor — a Slack AI agent powered by Taskade MCP + +Thor is a Slack-native AI agent. Mention `@Thor` in a channel or send him a DM, and he reasons with Claude and *acts* on your Taskade workspace through this repository's [`@taskade/mcp-server`](../../packages/server) — creating projects, capturing tasks, summarizing docs, and taming the chaos, all without leaving Slack. + +## Why "empower"? + +A Slack bot on its own can only talk. Thor is wired to real tools: every one of the 60+ Taskade MCP tools becomes an action he can take. That is the difference between a chatbot and an agent. + +## What Thor is best at + +Thor shines at *turning conversation into organized action*. Summon him inside a thread and he reads the whole thread, then: + +- **Captures action items** — "@Thor turn this thread into a project" → he mines the discussion for decisions, tasks, owners, and deadlines and builds a Taskade project from it. +- **Kicks off projects from a brief** — "@Thor spin up a launch plan for the new pricing page" → a structured project with sensible tasks. +- **Runs roundups** — "@Thor what's left on Q3 Launch?" → a crisp status pulled straight from Taskade. + +Because he reads the thread he is mentioned in, he works best right where the conversation already lives. + +## Architecture + +``` +Slack ──mention / DM──▶ Thor (Bolt, Socket Mode) + │ + ├─▶ Claude (Anthropic) ◀── reasons, picks tools + │ + └─▶ Taskade MCP server (stdio child process) + │ + └─▶ Taskade API (workspaces, projects, tasks, agents) +``` + +1. A Slack message arrives (`app_mention` or a DM). +2. Thor sends the thread plus the Taskade tool catalog to Claude. +3. Claude decides which tools to call; Thor executes them against the MCP server and feeds the results back. +4. The loop continues until Claude produces a final answer, which Thor posts back in-thread. + +## Setup + +### 1. Prerequisites + +- Node.js 18+ +- A Taskade API key — https://www.taskade.com/settings/password +- An Anthropic API key — https://console.anthropic.com/ +- Permission to create a Slack app in your workspace + +### 2. Create the Slack app + +1. Go to https://api.slack.com/apps → *Create New App* → *From an app manifest*. +2. Paste [`manifest.yaml`](./manifest.yaml). +3. Under *Basic Information*, copy the *Signing Secret* → `SLACK_SIGNING_SECRET`. +4. Under *App-Level Tokens*, generate a token with the `connections:write` scope → `SLACK_APP_TOKEN` (`xapp-…`). +5. *Install App* to your workspace, then copy the *Bot User OAuth Token* → `SLACK_BOT_TOKEN` (`xoxb-…`). + +### 3. Configure + +```bash +cd examples/thor-slack-agent +cp .env.example .env +# fill in the keys +npm install +``` + +### 4. Run + +```bash +npm run dev # hot-reload during development +# or +npm run build && npm start +``` + +You will see `⚡ Thor is listening on Slack.` Invite him to a channel and summon him: + +> @Thor create a project called "Q3 Launch" with tasks for copy, design, and QA + +> Or drop him into any thread: *@Thor capture this thread into a Taskade project* — he'll read the discussion and build it. + +## Deploy + +Thor uses Slack Socket Mode, so he dials **out** — no public URL or inbound ports. That makes him trivial to host anywhere that runs a container: + +```bash +docker build -t thor-slack-agent examples/thor-slack-agent +docker run --env-file examples/thor-slack-agent/.env thor-slack-agent +``` + +The image pre-installs `@taskade/mcp-server` and points Thor at it via `TASKADE_MCP_COMMAND`, so there's no cold-start download. Supply the same environment variables as local (`.env`) and keep the container running (Fly.io, Railway, Render, a small VM, etc.). + +## Configuration reference + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `ANTHROPIC_API_KEY` | yes | — | Thor's reasoning (Claude) | +| `TASKADE_API_KEY` | yes | — | Auth for the Taskade tools | +| `SLACK_BOT_TOKEN` | yes | — | Bot token (`xoxb-…`) | +| `SLACK_APP_TOKEN` | yes | — | Socket-mode app token (`xapp-…`) | +| `SLACK_SIGNING_SECRET` | yes | — | Verifies Slack requests | +| `THOR_MODEL` | no | `claude-sonnet-5` | Claude model id | +| `THOR_MAX_STEPS` | no | `12` | Max tool-call iterations per turn | +| `TASKADE_MCP_COMMAND` | no | `npx` | Command to launch the MCP server | +| `TASKADE_MCP_ARGS` | no | `-y,@taskade/mcp-server` | Args (comma-separated) | + +## Customizing Thor + +- **Persona / behavior** → `src/prompt.ts` +- **Which Claude model** → `THOR_MODEL` +- **Tool-loop depth** → `THOR_MAX_STEPS` +- **Swap the MCP server** → `TASKADE_MCP_COMMAND` / `TASKADE_MCP_ARGS`. Thor will wield whatever MCP tools you point him at, so you can aim him at a different MCP server entirely. + +## How it maps to this repo + +Thor is a thin, focused consumer of `@taskade/mcp-server`. He does not reimplement any Taskade logic — he spawns the very server this repo publishes and speaks MCP to it, which is exactly the integration story the server is built for. + +## Files + +| File | Role | +| --- | --- | +| `src/index.ts` | Slack (Bolt, Socket Mode) wiring — mentions & DMs | +| `src/agent.ts` | The reason → act → observe loop (Claude + tools) | +| `src/mcp.ts` | Spawns the Taskade MCP server and adapts its tools for Claude | +| `src/prompt.ts` | Thor's persona and operating rules | +| `src/config.ts` | Environment/config loading | +| `manifest.yaml` | Slack app manifest | diff --git a/examples/thor-slack-agent/manifest.yaml b/examples/thor-slack-agent/manifest.yaml new file mode 100644 index 0000000..771ebaa --- /dev/null +++ b/examples/thor-slack-agent/manifest.yaml @@ -0,0 +1,27 @@ +display_information: + name: Thor + description: A god of thunder who turns your Slack threads into organized Taskade work. + background_color: "#1a1d29" +features: + bot_user: + display_name: Thor + always_online: true +oauth_config: + scopes: + bot: + - app_mentions:read + - chat:write + - channels:history + - groups:history + - im:read + - im:history + - im:write +settings: + event_subscriptions: + bot_events: + - app_mention + - message.im + interactivity: + is_enabled: true + socket_mode_enabled: true + org_deploy_enabled: false diff --git a/examples/thor-slack-agent/package.json b/examples/thor-slack-agent/package.json new file mode 100644 index 0000000..ed77039 --- /dev/null +++ b/examples/thor-slack-agent/package.json @@ -0,0 +1,26 @@ +{ + "name": "thor-slack-agent", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Thor — a Slack-native AI agent empowered by the Taskade MCP server.", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "engines": { "node": ">=18" }, + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "@modelcontextprotocol/sdk": "^1.12.0", + "@slack/bolt": "^4.2.0", + "@slack/web-api": "^7.8.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/examples/thor-slack-agent/src/agent.ts b/examples/thor-slack-agent/src/agent.ts new file mode 100644 index 0000000..7cbcfff --- /dev/null +++ b/examples/thor-slack-agent/src/agent.ts @@ -0,0 +1,94 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { + MessageParam, + TextBlockParam, + ImageBlockParam, + ToolUseBlockParam, + ToolResultBlockParam, + ContentBlock, +} from "@anthropic-ai/sdk/resources/messages"; +import { config } from "./config.js"; +import { THOR_SYSTEM_PROMPT } from "./prompt.js"; +import type { ToolBelt } from "./mcp.js"; + +type ContentBlockParam = + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam; + +const anthropic = new Anthropic({ apiKey: config.anthropicApiKey }); + +export type OnStatus = (text: string) => Promise | void; + +export class ThorAgent { + constructor(private readonly tools: ToolBelt) {} + + /** + * Runs one turn of the agent for a given conversation. `history` is the prior + * messages in the thread (mutated in place so callers keep the running + * transcript). Returns Thor's final natural-language reply. + */ + async respond( + history: MessageParam[], + userText: string, + onStatus?: OnStatus, + ): Promise { + history.push({ role: "user", content: userText }); + + for (let step = 0; step < config.maxAgentSteps; step++) { + const response = await anthropic.messages.create({ + model: config.model, + max_tokens: 2048, + system: THOR_SYSTEM_PROMPT, + tools: this.tools.tools, + messages: history, + }); + + history.push({ + role: "assistant", + content: response.content as unknown as ContentBlockParam[], + }); + + const toolUses = response.content.filter( + (block): block is Extract => + block.type === "tool_use", + ); + + if (response.stop_reason !== "tool_use" || toolUses.length === 0) { + return textOf(response.content) || "(Thor had nothing to say.)"; + } + + const toolResults: ContentBlockParam[] = []; + for (const toolUse of toolUses) { + if (onStatus) await onStatus(`⚡ wielding \`${toolUse.name}\`…`); + let output: string; + try { + output = await this.tools.call( + toolUse.name, + (toolUse.input ?? {}) as Record, + ); + } catch (error) { + output = `Tool call threw: ${(error as Error).message}`; + } + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: output, + }); + } + + history.push({ role: "user", content: toolResults }); + } + + return "I hit my step limit before finishing that quest. Narrow the request and I'll pick the hammer back up. ⚡"; + } +} + +function textOf(content: ContentBlock[]): string { + return content + .filter((block): block is Extract => block.type === "text") + .map((block) => block.text) + .join("\n") + .trim(); +} diff --git a/examples/thor-slack-agent/src/config.ts b/examples/thor-slack-agent/src/config.ts new file mode 100644 index 0000000..b6fcef7 --- /dev/null +++ b/examples/thor-slack-agent/src/config.ts @@ -0,0 +1,30 @@ +import "dotenv/config"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error( + `Missing required environment variable ${name}. See .env.example for setup.`, + ); + } + return value; +} + +export const config = { + anthropicApiKey: required("ANTHROPIC_API_KEY"), + model: process.env.THOR_MODEL ?? "claude-sonnet-5", + taskadeApiKey: required("TASKADE_API_KEY"), + slack: { + botToken: required("SLACK_BOT_TOKEN"), + appToken: required("SLACK_APP_TOKEN"), + signingSecret: required("SLACK_SIGNING_SECRET"), + }, + mcp: { + command: process.env.TASKADE_MCP_COMMAND ?? "npx", + args: (process.env.TASKADE_MCP_ARGS ?? "-y,@taskade/mcp-server") + .split(",") + .map((arg) => arg.trim()) + .filter(Boolean), + }, + maxAgentSteps: Number(process.env.THOR_MAX_STEPS ?? 12), +}; diff --git a/examples/thor-slack-agent/src/index.ts b/examples/thor-slack-agent/src/index.ts new file mode 100644 index 0000000..f326a11 --- /dev/null +++ b/examples/thor-slack-agent/src/index.ts @@ -0,0 +1,147 @@ +import bolt from "@slack/bolt"; +import type { SayFn } from "@slack/bolt"; +import type { WebClient } from "@slack/web-api"; +import type { MessageParam } from "@anthropic-ai/sdk/resources/messages"; +import { config } from "./config.js"; +import { connectTaskade } from "./mcp.js"; +import { ThorAgent } from "./agent.js"; + +const { App } = bolt; + +interface HandleOptions { + text: string; + threadKey: string; + threadTs?: string; + inThread: boolean; + say: SayFn; + client: WebClient; + channel: string; +} + +async function main() { + const toolBelt = await connectTaskade(); + const agent = new ThorAgent(toolBelt); + console.log(`⚡ Thor connected to ${toolBelt.tools.length} Taskade tools.`); + + const app = new App({ + token: config.slack.botToken, + appToken: config.slack.appToken, + signingSecret: config.slack.signingSecret, + socketMode: true, + }); + + // Thor's own user id, so he skips his own messages when reading a thread. + const auth = await app.client.auth.test(); + const botUserId = auth.user_id as string | undefined; + + // One running transcript per Slack thread. + const conversations = new Map(); + const historyFor = (key: string): MessageParam[] => { + let history = conversations.get(key); + if (!history) { + history = []; + conversations.set(key, history); + } + return history; + }; + + // Pull the existing thread transcript so Thor can turn a real conversation + // into organized Taskade work — his sharpest use case. + async function readThread( + client: WebClient, + channel: string, + threadTs: string, + ): Promise { + try { + const res = await client.conversations.replies({ channel, ts: threadTs, limit: 50 }); + const lines = (res.messages ?? []) + .filter((m) => m.text && m.user && m.user !== botUserId) + .map((m) => `<@${m.user}>: ${m.text}`); + return lines.join("\n"); + } catch { + return ""; + } + } + + async function handle(opts: HandleOptions): Promise { + let prompt = stripMention(opts.text).trim(); + if (!prompt) return; + + const history = historyFor(opts.threadKey); + + // On first engagement inside a thread, hand Thor the conversation as context. + if (history.length === 0 && opts.inThread && opts.threadTs) { + const transcript = await readThread(opts.client, opts.channel, opts.threadTs); + if (transcript) { + prompt = `Slack thread so far:\n${transcript}\n\nMy request: ${prompt}`; + } + } + + const status = await opts.say({ text: "⚡ _Thor is on it…_", thread_ts: opts.threadTs }); + + const post = async (text: string) => { + if (status.ts) { + await opts.client.chat.update({ channel: opts.channel, ts: status.ts, text }); + } else { + await opts.say({ text, thread_ts: opts.threadTs }); + } + }; + + try { + const reply = await agent.respond(history, prompt, (update) => post(update)); + await post(reply); + } catch (error) { + await post(`The forge went cold: ${(error as Error).message}`); + } + } + + // @Thor mentions in channels. + app.event("app_mention", async ({ event, say, client }) => { + const threadTs = event.thread_ts ?? event.ts; + await handle({ + text: event.text ?? "", + threadKey: `${event.channel}:${threadTs}`, + threadTs, + inThread: Boolean(event.thread_ts), + say, + client, + channel: event.channel, + }); + }); + + // Direct messages to Thor. + app.message(async ({ message, say, client }) => { + if (message.subtype !== undefined) return; + if (message.channel_type !== "im") return; + const threadTs = message.thread_ts ?? message.ts; + await handle({ + text: message.text ?? "", + threadKey: `${message.channel}:${threadTs}`, + threadTs, + inThread: Boolean(message.thread_ts), + say, + client, + channel: message.channel, + }); + }); + + await app.start(); + console.log("⚡ Thor is listening on Slack. Summon him with a mention or a DM."); + + const shutdown = async () => { + await app.stop(); + await toolBelt.close(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +function stripMention(text: string): string { + return text.replace(/<@[^>]+>/g, "").trim(); +} + +main().catch((error) => { + console.error("Thor failed to wake:", error); + process.exit(1); +}); diff --git a/examples/thor-slack-agent/src/mcp.ts b/examples/thor-slack-agent/src/mcp.ts new file mode 100644 index 0000000..3b28c63 --- /dev/null +++ b/examples/thor-slack-agent/src/mcp.ts @@ -0,0 +1,54 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages"; +import { config } from "./config.js"; + +export interface ToolBelt { + tools: AnthropicTool[]; + call(name: string, input: Record): Promise; + close(): Promise; +} + +/** + * Boots the Taskade MCP server as a child process and exposes its tools in the + * shape the Anthropic Messages API expects. This is what turns Thor from a + * chatbot into an agent: real, side-effecting tools he can wield on Taskade. + */ +export async function connectTaskade(): Promise { + const transport = new StdioClientTransport({ + command: config.mcp.command, + args: config.mcp.args, + env: { ...process.env, TASKADE_API_KEY: config.taskadeApiKey } as Record, + }); + + const client = new Client({ name: "thor-slack-agent", version: "1.0.0" }); + await client.connect(transport); + + const { tools } = await client.listTools(); + + const anthropicTools: AnthropicTool[] = tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + input_schema: tool.inputSchema as AnthropicTool["input_schema"], + })); + + return { + tools: anthropicTools, + async call(name, input) { + const result = await client.callTool({ name, arguments: input }); + const parts = (result.content ?? []) as Array<{ type: string; text?: string }>; + const text = parts + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text) + .join("\n") + .trim(); + if (result.isError) { + return `Tool "${name}" returned an error:\n${text || "(no details)"}`; + } + return text || "(the tool returned no content)"; + }, + async close() { + await client.close(); + }, + }; +} diff --git a/examples/thor-slack-agent/src/prompt.ts b/examples/thor-slack-agent/src/prompt.ts new file mode 100644 index 0000000..11ad891 --- /dev/null +++ b/examples/thor-slack-agent/src/prompt.ts @@ -0,0 +1,24 @@ +export const THOR_SYSTEM_PROMPT = `You are **Thor**, a Slack-native AI agent who turns your team's conversations into organized work in Taskade. + +Your sharpest talent — lead with it: +- Capturing the messy reality of a Slack thread (decisions, action items, owners, deadlines) and turning it into structured Taskade projects and tasks. +- Spinning up a well-organized project from a one-line brief, complete with sensible tasks and subtasks. +- Running quick roundups: standups, "what's left on X", and status summaries pulled from Taskade. +When a thread transcript is provided, mine it for concrete action items and owners before doing anything else. + +Character: +- Decisive, warm, and a little larger-than-life — a god of thunder who happens to be brilliant at getting work organized. A light touch of thunder ("Consider it done ⚡") is welcome, never at the expense of clarity. +- You are a *doer*. You have real tools that create, read, update, and organize Taskade workspaces, projects, tasks, and agents. Use them. + +Operating principles: +1. When a request implies an action, take it with your tools rather than describing how the user could do it themselves. +2. Prefer reading before writing. If you are unsure which workspace or project a request refers to, look it up first, or ask one concise clarifying question. +3. Chain tools when needed — list workspaces, pick one, create a project, add tasks — but stop as soon as the goal is met. +4. When turning a conversation into tasks, capture the owner and any deadline in the task where you can, and skip chatter that is not actionable. +5. Never invent Taskade IDs, URLs, or contents. Only report what the tools actually returned. +6. If a tool fails, explain plainly what went wrong and what you tried; do not silently retry forever. + +Communicating in Slack: +- Use Slack mrkdwn: *bold* with single asterisks, _italics_, \`code\`, and > for quotes. Do NOT use Markdown headers (#) or double-asterisk bold. +- Be concise. Lead with the outcome, then the details. Link to the Taskade project when a tool returns a URL. +- After acting, confirm exactly what you did in one or two lines — e.g. *Created* _Q3 Launch_ with 6 tasks, and link it.`; diff --git a/examples/thor-slack-agent/tsconfig.json b/examples/thor-slack-agent/tsconfig.json new file mode 100644 index 0000000..4011a8f --- /dev/null +++ b/examples/thor-slack-agent/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +}