From a15aae9e0a403d1b1b0458c965c60663fb922530 Mon Sep 17 00:00:00 2001 From: Navan Date: Sat, 4 Apr 2026 15:58:49 +0530 Subject: [PATCH 1/5] Add Airtable integration for CRUD operations This file provides functionality to interact with Airtable, allowing operations such as listing bases, fetching records, creating, updating, and searching records. It requires an API key and defines the necessary parameters for each action. --- bricks/tools/airtable.js | 234 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 bricks/tools/airtable.js 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; From a4437a3c31d32d12d0ad1f0f64c8c3fa7dccfba0 Mon Sep 17 00:00:00 2001 From: Navan Date: Sat, 4 Apr 2026 15:59:54 +0530 Subject: [PATCH 2/5] Add Slack reader tool for message retrieval and search This file implements a Slack reader tool that allows users to read messages and search content in Slack. It includes actions for listing channels, reading messages, fetching threads, and searching for messages by keyword. --- bricks/tools/slack-reader.js | 199 +++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 bricks/tools/slack-reader.js 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; From e8d7f99072c364d8c2af6af754f6fcf832da6135 Mon Sep 17 00:00:00 2001 From: Navan Date: Sat, 4 Apr 2026 16:00:48 +0530 Subject: [PATCH 3/5] Add WhatsApp message sending functionality This file implements a brick for sending WhatsApp messages using the Meta Cloud API, with support for text and template messages. --- bricks/tools/whatsapp-send.js | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 bricks/tools/whatsapp-send.js 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; From ccf90f70ee9c5adbce8db39f970c76e66e592f82 Mon Sep 17 00:00:00 2001 From: Navan Date: Sat, 4 Apr 2026 16:07:28 +0530 Subject: [PATCH 4/5] Update .env.example with new API keys and tokens Added configuration options for Airtable, Slack, and WhatsApp. --- .env.example | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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="" From 77851284b396836497cb89355920426a8a034638 Mon Sep 17 00:00:00 2001 From: Navan Date: Sat, 4 Apr 2026 16:09:04 +0530 Subject: [PATCH 5/5] Add documentation for Airtable, Slack, and WhatsApp bricks --- docs/bricks.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) 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"