diff --git a/.env.example b/.env.example index 0a6b852..dc52167 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,20 @@ NEXT_PUBLIC_AGENT_NAME=My Agent AI_PROVIDER=groq AI_MODEL=llama-3.3-70b-versatile GROQ_API_KEY= + +# ── Brick: Airtable ───────────────────────────────────────────────────────── +# Airtable Personal Access Token → https://airtable.com/create/tokens +# Scopes needed: data.records:read, data.records:write, schema.bases:read +AIRTABLE_API_KEY="" + +# ── Brick: Slack Reader ────────────────────────────────────────────────────── +# Same Slack Bot Token as slack-send — no new token needed if slack-send is enabled +# Create at: https://api.slack.com/apps → OAuth & Permissions +# Scopes: channels:read, channels:history, groups:read, groups:history, search:read +SLACK_BOT_TOKEN="" + +# ── Brick: WhatsApp Send ───────────────────────────────────────────────────── +# Meta WhatsApp Business Cloud API (free test number available) +# Setup: https://developers.facebook.com → Create App → Add WhatsApp product +WHATSAPP_PHONE_NUMBER_ID="" +WHATSAPP_ACCESS_TOKEN="" diff --git a/bricks/tools/airtable.js b/bricks/tools/airtable.js new file mode 100644 index 0000000..c7b1d27 --- /dev/null +++ b/bricks/tools/airtable.js @@ -0,0 +1,234 @@ +/** + * bricks/tools/airtable.js — Read and write Airtable bases + * + * Requires: AIRTABLE_API_KEY in .env.local + * Get your key: https://airtable.com/create/tokens (Personal Access Token) + * Scopes needed: data.records:read, data.records:write, schema.bases:read + * + * @type {import('@/core/types.js').Brick} + */ +import * as z from "zod"; + +const AIRTABLE_BASE_URL = "https://api.airtable.com/v0"; + +const brick = { + name: "airtable", + description: + "Read and write records in Airtable bases and tables. " + + "Actions: list-bases (show all accessible bases), list-records (fetch rows from a table), " + + "get-record (fetch a single row by ID), create-record (add a new row), " + + "update-record (edit an existing row), search-records (filter rows by a field value). " + + "Use for managing leads, contacts, tasks, or any structured data stored in Airtable.", + + requiredEnvVars: ["AIRTABLE_API_KEY"], + + parameters: z.object({ + action: z + .enum([ + "list-bases", + "list-records", + "get-record", + "create-record", + "update-record", + "search-records", + ]) + .describe( + "Airtable operation: list-bases=show all bases, list-records=fetch rows, " + + "get-record=fetch one row by ID, create-record=add a new row, " + + "update-record=edit a row, search-records=filter rows by field value." + ), + + baseId: z + .string() + .optional() + .describe( + "Airtable base ID (starts with 'app'). Find it in your base URL: " + + "airtable.com/{baseId}/{tableId}. Required for all actions except list-bases." + ), + + tableId: z + .string() + .optional() + .describe( + "Table name or table ID (starts with 'tbl'). " + + "Required for list-records, get-record, create-record, update-record, search-records." + ), + + recordId: z + .string() + .optional() + .describe( + "Airtable record ID (starts with 'rec'). Required for get-record and update-record." + ), + + fields: z + .record(z.unknown()) + .optional() + .describe( + "Field values as a key-value object. Used by create-record and update-record. " + + "Example: { 'Name': 'Acme Corp', 'Status': 'Contacted', 'Email': 'ceo@acme.com' }" + ), + + filterField: z + .string() + .optional() + .describe("Field name to filter by. Used by search-records."), + + filterValue: z + .string() + .optional() + .describe("Value to match in filterField. Used by search-records."), + + maxRecords: z + .number() + .int() + .min(1) + .max(100) + .optional() + .default(20) + .describe("Maximum number of records to return. Default 20, max 100."), + }), + + execute: async ({ + action, + baseId, + tableId, + recordId, + fields, + filterField, + filterValue, + maxRecords = 20, + }) => { + const apiKey = process.env.AIRTABLE_API_KEY; + + switch (action) { + case "list-bases": { + const data = await airtableFetch( + apiKey, + "https://api.airtable.com/v0/meta/bases", + "GET" + ); + return { + count: data.bases?.length ?? 0, + bases: (data.bases ?? []).map((b) => ({ + id: b.id, + name: b.name, + permissionLevel: b.permissionLevel, + })), + }; + } + + case "list-records": { + requireParams({ baseId, tableId }, ["baseId", "tableId"]); + const url = `${AIRTABLE_BASE_URL}/${baseId}/${encodeURIComponent(tableId)}?maxRecords=${maxRecords}`; + const data = await airtableFetch(apiKey, url, "GET"); + return { + count: data.records?.length ?? 0, + records: (data.records ?? []).map(shapeRecord), + offset: data.offset ?? null, + }; + } + + case "get-record": { + requireParams({ baseId, tableId, recordId }, [ + "baseId", + "tableId", + "recordId", + ]); + const url = `${AIRTABLE_BASE_URL}/${baseId}/${encodeURIComponent(tableId)}/${recordId}`; + const data = await airtableFetch(apiKey, url, "GET"); + return shapeRecord(data); + } + + case "create-record": { + requireParams({ baseId, tableId, fields }, ["baseId", "tableId", "fields"]); + const url = `${AIRTABLE_BASE_URL}/${baseId}/${encodeURIComponent(tableId)}`; + const data = await airtableFetch(apiKey, url, "POST", { + fields, + }); + return { + success: true, + record: shapeRecord(data), + }; + } + + case "update-record": { + requireParams({ baseId, tableId, recordId, fields }, [ + "baseId", + "tableId", + "recordId", + "fields", + ]); + const url = `${AIRTABLE_BASE_URL}/${baseId}/${encodeURIComponent(tableId)}/${recordId}`; + const data = await airtableFetch(apiKey, url, "PATCH", { fields }); + return { + success: true, + record: shapeRecord(data), + }; + } + + case "search-records": { + requireParams({ baseId, tableId, filterField, filterValue }, [ + "baseId", + "tableId", + "filterField", + "filterValue", + ]); + const formula = encodeURIComponent( + `{${filterField}}="${filterValue}"` + ); + const url = `${AIRTABLE_BASE_URL}/${baseId}/${encodeURIComponent(tableId)}?filterByFormula=${formula}&maxRecords=${maxRecords}`; + const data = await airtableFetch(apiKey, url, "GET"); + return { + count: data.records?.length ?? 0, + records: (data.records ?? []).map(shapeRecord), + }; + } + + default: + return { error: `Unknown action: ${action}` }; + } + }, + + onError: (err) => + `Airtable error: ${err.message}. Check AIRTABLE_API_KEY in .env.local and ensure your token has the required scopes.`, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function airtableFetch(apiKey, url, method, body) { + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(12_000), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message ?? `Airtable API HTTP ${res.status}`); + } + + return res.json(); +} + +function shapeRecord(record) { + return { + id: record.id, + fields: record.fields ?? {}, + createdTime: record.createdTime ?? null, + }; +} + +function requireParams(params, required) { + for (const key of required) { + if (params[key] === undefined || params[key] === null) { + throw new Error(`Missing required parameter: ${key}`); + } + } +} + +export default brick; diff --git a/bricks/tools/slack-reader.js b/bricks/tools/slack-reader.js new file mode 100644 index 0000000..58b9854 --- /dev/null +++ b/bricks/tools/slack-reader.js @@ -0,0 +1,199 @@ +/** + * bricks/tools/slack-reader.js — Read messages and search Slack + * + * Requires: SLACK_BOT_TOKEN in .env.local (same token as slack-send.js) + * Scopes needed: channels:read, channels:history, groups:read, groups:history, + * im:read, im:history, search:read + * + * @type {import('@/core/types.js').Brick} + */ +import * as z from "zod"; + +const brick = { + name: "slack-reader", + description: + "Read messages and search content in Slack. " + + "Actions: list-channels (show all accessible channels), " + + "read-messages (fetch recent messages from a channel), " + + "get-thread (fetch all replies in a message thread), " + + "search (find messages by keyword across the workspace). " + + "Use to check replies, monitor conversations, or retrieve context before responding.", + + requiredEnvVars: ["SLACK_BOT_TOKEN"], + + parameters: z.object({ + action: z + .enum(["list-channels", "read-messages", "get-thread", "search"]) + .describe( + "Slack read operation: list-channels=show available channels, " + + "read-messages=fetch recent messages from a channel, " + + "get-thread=fetch all replies to a message, " + + "search=find messages by keyword." + ), + + channelId: z + .string() + .optional() + .describe( + "Slack channel ID (starts with C, G, or D). " + + "Required for read-messages and get-thread. " + + "Use list-channels to find channel IDs." + ), + + threadTs: z + .string() + .optional() + .describe( + "Timestamp of the parent message in a thread. " + + "Looks like '1709123456.123456'. Required for get-thread." + ), + + query: z + .string() + .optional() + .describe( + "Search query string. Used by search. " + + "Supports Slack search modifiers: in:#channel, from:@user, before:2026-01-01." + ), + + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .default(20) + .describe("Maximum number of messages or channels to return. Default 20."), + }), + + execute: async ({ action, channelId, threadTs, query, limit = 20 }) => { + const token = process.env.SLACK_BOT_TOKEN; + + switch (action) { + case "list-channels": { + const data = await slackFetch(token, "conversations.list", { + types: "public_channel,private_channel", + limit, + exclude_archived: true, + }); + return { + count: data.channels?.length ?? 0, + channels: (data.channels ?? []).map((c) => ({ + id: c.id, + name: c.name, + isPrivate: c.is_private, + isMember: c.is_member, + memberCount: c.num_members ?? null, + topic: c.topic?.value ?? "", + })), + }; + } + + case "read-messages": { + if (!channelId) { + throw new Error( + "Provide channelId to read messages. Use list-channels to find channel IDs." + ); + } + const data = await slackFetch(token, "conversations.history", { + channel: channelId, + limit, + }); + return { + count: data.messages?.length ?? 0, + hasMore: data.has_more ?? false, + messages: (data.messages ?? []).map(shapeMessage), + }; + } + + case "get-thread": { + if (!channelId || !threadTs) { + throw new Error( + "Provide both channelId and threadTs to fetch a thread. " + + "threadTs is the timestamp of the parent message." + ); + } + const data = await slackFetch(token, "conversations.replies", { + channel: channelId, + ts: threadTs, + limit, + }); + const messages = data.messages ?? []; + return { + threadTs, + replyCount: messages.length > 0 ? messages.length - 1 : 0, + messages: messages.map(shapeMessage), + }; + } + + case "search": { + if (!query) { + throw new Error("Provide a query string to search Slack."); + } + const data = await slackFetch(token, "search.messages", { + query, + count: limit, + sort: "timestamp", + sort_dir: "desc", + }); + const matches = data.messages?.matches ?? []; + return { + count: data.messages?.total ?? matches.length, + query, + messages: matches.map((m) => ({ + ts: m.ts, + text: m.text ?? "", + user: m.username ?? m.user ?? "unknown", + channel: m.channel?.name ?? m.channel?.id ?? "", + permalink: m.permalink ?? null, + })), + }; + } + + default: + return { error: `Unknown action: ${action}` }; + } + }, + + onError: (err) => + `Slack reader error: ${err.message}. Ensure SLACK_BOT_TOKEN is set and the bot has been added to the target channels.`, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function slackFetch(token, method, params = {}) { + const url = new URL(`https://slack.com/api/${method}`); + for (const [key, val] of Object.entries(params)) { + url.searchParams.set(key, String(val)); + } + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(12_000), + }); + + if (!res.ok) { + throw new Error(`Slack API HTTP ${res.status}`); + } + + const data = await res.json(); + if (!data.ok) { + throw new Error(`Slack API error: ${data.error}`); + } + + return data; +} + +function shapeMessage(msg) { + return { + ts: msg.ts, + text: msg.text ?? "", + user: msg.user ?? msg.bot_id ?? "unknown", + replyCount: msg.reply_count ?? 0, + reactions: (msg.reactions ?? []).map((r) => `${r.name} x${r.count}`), + hasThread: !!msg.thread_ts && msg.thread_ts !== msg.ts, + threadTs: msg.thread_ts ?? null, + }; +} + +export default brick; diff --git a/bricks/tools/whatsapp-send.js b/bricks/tools/whatsapp-send.js new file mode 100644 index 0000000..3fca6aa --- /dev/null +++ b/bricks/tools/whatsapp-send.js @@ -0,0 +1,160 @@ +/** + * bricks/tools/whatsapp-send.js — Send WhatsApp messages via Meta Cloud API + * + * Requires: + * WHATSAPP_PHONE_NUMBER_ID — the Phone Number ID from Meta Business dashboard + * WHATSAPP_ACCESS_TOKEN — your permanent or temporary access token + * + * Setup (free): + * 1. Create a Meta Developer app: https://developers.facebook.com + * 2. Add the WhatsApp product to your app + * 3. Use the test number provided (or add your own) + * 4. Copy Phone Number ID and generate an access token + * + * Note: Recipients must have messaged your number first (24h window), + * or you must use a pre-approved template message. + * + * @type {import('@/core/types.js').Brick} + */ +import * as z from "zod"; + +const WA_API_VERSION = "v19.0"; + +const brick = { + name: "whatsapp-send", + description: + "Send a WhatsApp message via the Meta WhatsApp Business Cloud API. " + + "Two modes: text (send a free-form message within the 24-hour reply window), " + + "template (send a pre-approved template message to any number at any time). " + + "Use for outreach follow-ups, notifications, or automated WhatsApp communication.", + + requiredEnvVars: ["WHATSAPP_PHONE_NUMBER_ID", "WHATSAPP_ACCESS_TOKEN"], + + parameters: z.object({ + to: z + .string() + .describe( + "Recipient phone number in E.164 format, e.g. '+919876543210'. " + + "Include country code, no spaces or dashes." + ), + + type: z + .enum(["text", "template"]) + .default("text") + .describe( + "Message type: text=free-form message (only within 24h of last user message), " + + "template=pre-approved template message (can be sent any time)." + ), + + message: z + .string() + .min(1) + .max(4_096) + .optional() + .describe("Message text. Required when type is 'text'."), + + templateName: z + .string() + .optional() + .describe( + "Name of the pre-approved template. Required when type is 'template'. " + + "Example: 'hello_world'" + ), + + templateLanguage: z + .string() + .optional() + .default("en_US") + .describe( + "BCP-47 language code for the template. Defaults to 'en_US'. " + + "Example: 'en_GB', 'hi', 'es_MX'." + ), + + previewUrl: z + .boolean() + .optional() + .default(false) + .describe("Enable link preview in text messages."), + }), + + execute: async ({ + to, + type = "text", + message, + templateName, + templateLanguage = "en_US", + previewUrl = false, + }) => { + const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; + const accessToken = process.env.WHATSAPP_ACCESS_TOKEN; + + const url = `https://graph.facebook.com/${WA_API_VERSION}/${phoneNumberId}/messages`; + + let body; + + if (type === "text") { + if (!message) { + throw new Error("Provide a message when type is 'text'."); + } + body = { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type: "text", + text: { + preview_url: previewUrl, + body: message, + }, + }; + } else if (type === "template") { + if (!templateName) { + throw new Error( + "Provide templateName when type is 'template'. " + + "Templates must be pre-approved in your Meta Business dashboard." + ); + } + body = { + messaging_product: "whatsapp", + to, + type: "template", + template: { + name: templateName, + language: { code: templateLanguage }, + }, + }; + } + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + + const data = await res.json(); + + if (!res.ok || data.error) { + throw new Error( + data.error?.message ?? `WhatsApp API HTTP ${res.status}` + ); + } + + return { + success: true, + messageId: data.messages?.[0]?.id ?? null, + to, + type, + sentAt: new Date().toISOString(), + }; + }, + + onError: (err) => + `WhatsApp send failed: ${err.message}. ` + + "Check WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_ACCESS_TOKEN in .env.local. " + + "For text messages, ensure the recipient has messaged you within the last 24 hours.", +}; + +export default brick; diff --git a/docs/bricks.md b/docs/bricks.md index dbb1f0b..d49cac2 100644 --- a/docs/bricks.md +++ b/docs/bricks.md @@ -473,3 +473,66 @@ bricks: ["code-executor"] **Capabilities:** Execute JS or Python, access numpy/pandas/axios, configurable timeout up to 30 seconds. Returns stdout, stderr, and exit code. +--- + +### `airtable` +Read and write records in Airtable bases and tables. +``` +AIRTABLE_API_KEY="" +``` +Get a free Personal Access Token: [airtable.com/create/tokens](https://airtable.com/create/tokens) +Scopes needed: `data.records:read`, `data.records:write`, `schema.bases:read` + +```js +bricks: ["airtable"] +``` + +**Actions:** `list-bases`, `list-records`, `get-record`, `create-record`, `update-record`, `search-records` + +**Example prompts:** +- "Show me all records in my Leads table (base ID: appXXX)" +- "Add a new lead: Name=Acme Corp, Status=Contacted, Email=ceo@acme.com" +- "Find all records in my Leads table where Status is 'Replied'" +- "Update record recXXX — set Status to 'Closed'" + +*** + +### `slack-reader` +Read messages and search content in Slack workspaces. +``` +SLACK_BOT_TOKEN=xoxb-... +``` +Same token as `slack-send` — no new setup needed if that brick is already enabled. +Bot must be added to the channels it needs to read. + +```js +bricks: ["slack-reader"] +``` + +**Actions:** `list-channels`, `read-messages`, `get-thread`, `search` + +**Example prompts:** +- "Show me the last 10 messages in #outreach" +- "Search Slack for messages about 'Acme Corp'" +- "Fetch the full thread from this message timestamp: 1709123456.123456" +- "List all Slack channels I have access to" + +*** + +### `whatsapp-send` +Send WhatsApp messages via the Meta WhatsApp Business Cloud API. +``` +WHATSAPP_PHONE_NUMBER_ID="" +WHATSAPP_ACCESS_TOKEN="" +``` +Free setup: [developers.facebook.com](https://developers.facebook.com) → Create App → Add WhatsApp product → use test number provided. + +```js +bricks: ["whatsapp-send"] +``` + +**Modes:** `text` (free-form, within 24h reply window), `template` (pre-approved, send any time) + +**Example prompts:** +- "Send a WhatsApp message to +919876543210 saying 'Hey, following up on our chat!'" +- "Send the hello_world template to +44 7911 123456"