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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
234 changes: 234 additions & 0 deletions bricks/tools/airtable.js
Original file line number Diff line number Diff line change
@@ -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;
Loading