From 55f259b8bd2e83ac2c8e916b9c5553c264941d4d Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Thu, 10 Apr 2025 00:34:03 +0700 Subject: [PATCH] feat: add @agentic/airtable Airtable package --- docs/mint.json | 1 + docs/tools/airtable.mdx | 48 ++ packages/airtable/package.json | 45 ++ packages/airtable/readme.md | 24 + packages/airtable/src/airtable-client.ts | 423 ++++++++++++ packages/airtable/src/airtable.ts | 665 +++++++++++++++++++ packages/airtable/src/index.ts | 2 + packages/airtable/tsconfig.json | 5 + packages/open-meteo/src/open-meteo-client.ts | 3 + packages/reddit/src/reddit-client.ts | 3 + packages/stdlib/package.json | 1 + packages/stdlib/src/index.ts | 1 + packages/typeform/src/typeform-client.ts | 3 + packages/youtube/src/youtube-client.ts | 5 +- pnpm-lock.yaml | 18 + readme.md | 1 + 16 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 docs/tools/airtable.mdx create mode 100644 packages/airtable/package.json create mode 100644 packages/airtable/readme.md create mode 100644 packages/airtable/src/airtable-client.ts create mode 100644 packages/airtable/src/airtable.ts create mode 100644 packages/airtable/src/index.ts create mode 100644 packages/airtable/tsconfig.json diff --git a/docs/mint.json b/docs/mint.json index f529000b..6b8e000b 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -55,6 +55,7 @@ { "group": "Tools", "pages": [ + "tools/airtable", "tools/apollo", "tools/arxiv", "tools/bing", diff --git a/docs/tools/airtable.mdx b/docs/tools/airtable.mdx new file mode 100644 index 00000000..1bd4f65d --- /dev/null +++ b/docs/tools/airtable.mdx @@ -0,0 +1,48 @@ +--- +title: Airtable +description: Airtable is a no-code spreadsheets, CRM, and database. +--- + +- package: `@agentic/airtable` +- exports: `class AirtableClient`, `namespace airtable` +- env vars: `AIRTABLE_API_KEY` +- [source](https://github.com/transitive-bullshit/agentic/blob/main/packages/airtable/src/airtable-client.ts) +- [airtable api docs](https://airtable.com/developers/web/api/introduction) + +## Install + + +```bash npm +npm install @agentic/airtable +``` + +```bash yarn +yarn add @agentic/airtable +``` + +```bash pnpm +pnpm add @agentic/airtable +``` + + + +## Usage + +```ts +import { AirtableClient } from '@agentic/airtable' + +const airtable = new AirtableClient() +const { bases } = await airtable.listBases() +console.log('bases', tables) + +const baseId = bases[0]!.id +const tables = await airtable.listTables({ baseId }) +console.log('tables', tables) + +const searchResults = await airtable.searchRecords({ + baseId, + tableId: tables[0]!.id, + searchTerm: 'Travis' +}) +console.log('search results', searchResults) +``` diff --git a/packages/airtable/package.json b/packages/airtable/package.json new file mode 100644 index 00000000..d859b89f --- /dev/null +++ b/packages/airtable/package.json @@ -0,0 +1,45 @@ +{ + "name": "@agentic/airtable", + "version": "7.6.3", + "description": "Agentic SDK for Airtable.", + "author": "Travis Fischer ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/transitive-bullshit/agentic.git", + "directory": "packages/airtable" + }, + "type": "module", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "del dist", + "test": "run-s test:*", + "test:lint": "eslint .", + "test:typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentic/core": "workspace:*", + "ky": "catalog:", + "p-throttle": "catalog:" + }, + "peerDependencies": { + "zod": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/airtable/readme.md b/packages/airtable/readme.md new file mode 100644 index 00000000..38781f32 --- /dev/null +++ b/packages/airtable/readme.md @@ -0,0 +1,24 @@ +

+ + Agentic + +

+ +

+ AI agent stdlib that works with any LLM and TypeScript AI SDK. +

+ +

+ Build Status + NPM + MIT License + Prettier Code Formatting +

+ +# Agentic + +**See the [github repo](https://github.com/transitive-bullshit/agentic) or [docs](https://agentic.so) for more info.** + +## License + +MIT © [Travis Fischer](https://x.com/transitive_bs) diff --git a/packages/airtable/src/airtable-client.ts b/packages/airtable/src/airtable-client.ts new file mode 100644 index 00000000..1de0b839 --- /dev/null +++ b/packages/airtable/src/airtable-client.ts @@ -0,0 +1,423 @@ +import { + aiFunction, + AIFunctionsProvider, + assert, + getEnv, + sanitizeSearchParams +} from '@agentic/core' +import defaultKy, { type KyInstance } from 'ky' +import { z } from 'zod' + +import { airtable } from './airtable' + +/** + * Airtable API client. + * + * @see https://airtable.com/developers/web/api/introduction + */ +export class AirtableClient extends AIFunctionsProvider { + protected readonly ky: KyInstance + protected readonly apiKey: string + protected readonly apiBaseUrl: string + + constructor({ + apiKey = getEnv('AIRTABLE_API_KEY'), + apiBaseUrl = airtable.API_BASE_URL, + timeoutMs = 60_000, + ky = defaultKy + }: { + apiKey?: string + apiBaseUrl?: string + timeoutMs?: number + ky?: KyInstance + } = {}) { + assert( + apiKey, + `AirtableClient missing required "username" (defaults to "AIRTABLE_API_KEY")` + ) + super() + + this.apiKey = apiKey + this.apiBaseUrl = apiBaseUrl + + this.ky = ky.extend({ + prefixUrl: apiBaseUrl, + timeout: timeoutMs, + headers: { + Authorization: `Bearer ${apiKey}` + } + }) + } + + /** + * Lists all of the bases that the user has access to. + */ + @aiFunction({ + name: 'airtable_list_bases', + description: 'Lists all accessible Airtable bases.', + inputSchema: z.object({}) + }) + async listBases(): Promise { + return this.ky.get('v0/meta/bases').json() + } + + /** + * Lists all of the tables in a base. + */ + @aiFunction({ + name: 'airtable_list_tables', + description: 'Lists all of the tables in a base.', + inputSchema: airtable.ListTablesArgsSchema + }) + async listTables< + TDetailLevel extends airtable.TableDetailLevel = 'full' + >(args: { + baseId: string + detailLevel?: TDetailLevel + }): Promise>> { + const { baseId, detailLevel = 'full' } = args + + const res = await this.ky + .get(`/v0/meta/bases/${baseId}/tables`) + .json() + + return res.tables.map((table) => + transformTableDetailLevel({ + table, + detailLevel: detailLevel as TDetailLevel + }) + ) + } + + /** + * Gets a single table's schema in a base. + */ + @aiFunction({ + name: 'airtable_get_table', + description: "Gets a single table's schema in a base.", + inputSchema: airtable.DescribeTableArgsSchema + }) + async getTable< + TDetailLevel extends airtable.TableDetailLevel = 'full' + >(args: { + baseId: string + tableId: string + detailLevel?: TDetailLevel + }): Promise> { + const tables = await this.listTables(args) + const table = tables.find((t) => t.id === args.tableId) + assert(table, `Table ${args.tableId} not found in base ${args.baseId}`) + return table + } + + /** + * Lists records from a table. + */ + @aiFunction({ + name: 'airtable_list_records', + description: 'Lists records from a table.', + inputSchema: airtable.ListRecordsArgsSchema + }) + async listRecords( + args: airtable.ListRecordsArgs + ): Promise { + const { baseId, tableId, ...options } = args + return this.ky + .get(`/v0/${baseId}/${tableId}`, { + searchParams: sanitizeSearchParams(options) + }) + .json() + } + + /** + * Lists all records from a table. + */ + @aiFunction({ + name: 'airtable_list_all_records', + description: 'Lists all records from a table.', + inputSchema: airtable.ListRecordsArgsSchema + }) + async listAllRecords( + args: airtable.ListRecordsArgs + ): Promise { + const allRecords: airtable.AirtableRecord[] = [] + let offset = args.offset ?? 0 + + do { + const res = await this.listRecords({ + ...args, + offset + }) + if (!res.length) { + break + } + + allRecords.push(...res) + offset += res.length + } while (true) + + return allRecords + } + + /** + * Gets a single record from a table. + */ + @aiFunction({ + name: 'airtable_get_record', + description: 'Gets a single record from a table.', + inputSchema: airtable.GetRecordArgsSchema + }) + async getRecord( + args: airtable.GetRecordArgs + ): Promise { + const { baseId, tableId, recordId } = args + return this.ky + .get(`/v0/${baseId}/${tableId}/${recordId}`) + .json() + } + + /** + * Creates a record in a table. + */ + @aiFunction({ + name: 'airtable_create_record', + description: 'Creates a record in a table.', + inputSchema: airtable.CreateRecordArgsSchema + }) + async createRecord( + args: airtable.CreateRecordArgs + ): Promise { + const { baseId, tableId, ...body } = args + return this.ky + .post(`/v0/${baseId}/${tableId}`, { + json: body + }) + .json() + } + + /** + * Updates records in a table. + */ + @aiFunction({ + name: 'airtable_update_records', + description: 'Updates records in a table.', + inputSchema: airtable.UpdateRecordsArgsSchema + }) + async updateRecords( + args: airtable.UpdateRecordsArgs + ): Promise { + const { baseId, tableId, ...body } = args + return this.ky + .patch(`/v0/${baseId}/${tableId}`, { + json: body + }) + .json() + } + + /** + * Deletes records from a table. + */ + @aiFunction({ + name: 'airtable_delete_records', + description: 'Deletes records from a table.', + inputSchema: airtable.DeleteRecordsArgsSchema + }) + async deleteRecords( + args: airtable.DeleteRecordsArgs + ): Promise<{ id: string }[]> { + const { baseId, tableId, recordIds } = args + const queryString = recordIds.map((id) => `records[]=${id}`).join('&') + + const res = await this.ky + .delete(`/v0/${baseId}/${tableId}?${queryString}`) + .json<{ records: { id: string; deleted: boolean }[] }>() + + return res.records.map(({ id }) => ({ id })) + } + + /** + * Creates a table in a base. + */ + @aiFunction({ + name: 'airtable_create_table', + description: 'Creates a table in a base.', + inputSchema: airtable.CreateTableArgsSchema + }) + async createTable(args: airtable.CreateTableArgs): Promise { + const { baseId, ...body } = args + + return this.ky + .post(`/v0/meta/bases/${baseId}/tables`, { + json: body + }) + .json() + } + + /** + * Updates a table in a base. + */ + @aiFunction({ + name: 'airtable_update_table', + description: 'Updates a table in a base.', + inputSchema: airtable.UpdateTableArgsSchema + }) + async updateTable(args: airtable.UpdateTableArgs): Promise { + const { baseId, tableId, ...body } = args + return this.ky + .patch(`/v0/meta/bases/${baseId}/tables/${tableId}`, { + json: body + }) + .json() + } + + /** + * Creates a field in a table. + */ + @aiFunction({ + name: 'airtable_create_field', + description: 'Creates a field in a table.', + inputSchema: airtable.CreateFieldArgsSchema + }) + async createField(args: airtable.CreateFieldArgs): Promise { + const { baseId, tableId, body } = args + return this.ky + .post(`/v0/meta/bases/${baseId}/tables/${tableId}/fields`, { + json: body.field + }) + .json() + } + + /** + * Updates a field in a table. + */ + @aiFunction({ + name: 'airtable_update_field', + description: 'Updates a field in a table.', + inputSchema: airtable.UpdateFieldArgsSchema + }) + async updateField(args: airtable.UpdateFieldArgs): Promise { + const { baseId, tableId, fieldId, ...body } = args + return this.ky + .patch(`/v0/meta/bases/${baseId}/tables/${tableId}/fields/${fieldId}`, { + json: body + }) + .json() + } + + /** + * Searches for records in a table which contain specific text. + */ + @aiFunction({ + name: 'airtable_search_records', + description: 'Searches for records in a table which contain specific text.', + inputSchema: airtable.SearchRecordsArgsSchema + }) + async searchRecords( + args: airtable.SearchRecordsArgs + ): Promise { + const { baseId, tableId, fieldIds, searchTerm, ...opts } = args + // Validate and get search fields + const searchFieldIds = await this.validateAndGetSearchFields({ + baseId, + tableId, + fieldIds + }) + + // Escape the search term to prevent formula injection + const escapedSearchTerm = searchTerm.replaceAll(/["\\]/g, '\\$&') + + // Build OR(FIND("term", field1), FIND("term", field2), ...) + const filterByFormula = `OR(${searchFieldIds + .map((fieldId) => `FIND("${escapedSearchTerm}", {${fieldId}})`) + .join(',')})` + + return this.listRecords({ ...opts, baseId, tableId, filterByFormula }) + } + + /** + * Validates and gets the searchable text fields in a table. + */ + protected async validateAndGetSearchFields({ + baseId, + tableId, + fieldIds + }: { + baseId: string + tableId: string + fieldIds?: string[] + }): Promise { + const table = await this.getTable({ baseId, tableId }) + + const searchableFieldTypes = new Set([ + 'singleLineText', + 'multilineText', + 'richText', + 'email', + 'url', + 'phoneNumber' + ]) + + const searchableFieldIds = new Set( + table.fields + .filter((field) => searchableFieldTypes.has(field.type)) + .map((field) => field.id) + ) + + if (!searchableFieldIds.size) { + throw new Error('No text fields available to search') + } + + // If specific fields were requested, validate that they exist and are, in + // fact, valid searchable text fields. + if (fieldIds && fieldIds.length > 0) { + // Check if any requested fields were invalid + const invalidFieldIds = fieldIds.filter( + (fieldId) => !searchableFieldIds.has(fieldId) + ) + if (invalidFieldIds.length > 0) { + throw new Error( + `Invalid fields requested: ${invalidFieldIds.join(', ')}` + ) + } + + return fieldIds + } + + return Array.from(searchableFieldIds) + } +} + +function transformTableDetailLevel< + T extends airtable.TableDetailLevel = 'full' +>({ + table, + detailLevel +}: { + table: airtable.Table + detailLevel: T +}): airtable.AirtableTableToDetailLevel { + switch (detailLevel) { + case 'tableIdentifiersOnly': + return { + id: table.id, + name: table.name + } as any + + case 'identifiersOnly': + return { + id: table.id, + name: table.name, + fields: table.fields.map((field) => ({ + id: field.id, + name: field.name + })), + views: table.views.map((view) => ({ + id: view.id, + name: view.name + })) + } as any + + default: + return table as any + } +} diff --git a/packages/airtable/src/airtable.ts b/packages/airtable/src/airtable.ts new file mode 100644 index 00000000..505c24a7 --- /dev/null +++ b/packages/airtable/src/airtable.ts @@ -0,0 +1,665 @@ +import { z } from 'zod' + +export namespace airtable { + export const API_BASE_URL = 'https://api.airtable.com' + + export const BaseSchema = z.object({ + id: z.string(), + name: z.string(), + permissionLevel: z.string() + }) + export type Base = z.infer + + export const ListBasesResponseSchema = z.object({ + bases: z.array(BaseSchema), + offset: z.string().optional() + }) + export type ListBasesResponse = z.infer + + export const FieldOptionsSchema = z + .object({ + isReversed: z.boolean().optional(), + inverseLinkFieldId: z.string().optional(), + linkedTableId: z.string().optional(), + prefersSingleRecordLink: z.boolean().optional(), + color: z.string().optional(), + icon: z.string().optional() + }) + .passthrough() + export type FieldOptions = z.infer + + export const FieldSchema = z + .object({ + name: z.string(), + description: z.string().optional() + }) + .and( + // Extracted from Airtable API docs + z.union([ + z.object({ type: z.literal('autoNumber') }), + z.object({ type: z.literal('barcode') }), + z.object({ type: z.literal('button') }), + z + .object({ + options: z.object({ + color: z + .enum([ + 'greenBright', + 'tealBright', + 'cyanBright', + 'blueBright', + 'purpleBright', + 'pinkBright', + 'redBright', + 'orangeBright', + 'yellowBright', + 'grayBright' + ]) + .describe('The color of the checkbox.'), + icon: z + .enum([ + 'check', + 'xCheckbox', + 'star', + 'heart', + 'thumbsUp', + 'flag', + 'dot' + ]) + .describe('The icon name of the checkbox.') + }), + type: z.literal('checkbox') + }) + .describe( + "Bases on a free or plus plan are limited to using the `'check'` icon and `'greenBright'` color." + ), + z.object({ type: z.literal('createdBy') }), + z.object({ + options: z.object({ + result: z + .union([ + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + '`format` is always provided when reading.\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ), + name: z.enum([ + 'local', + 'friendly', + 'us', + 'european', + 'iso' + ]) + }) + }), + type: z.literal('date') + }), + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + '`format` is always provided when reading.\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ), + name: z.enum([ + 'local', + 'friendly', + 'us', + 'european', + 'iso' + ]) + }), + timeFormat: z.object({ + format: z.enum(['h:mma', 'HH:mm']), + name: z.enum(['12hour', '24hour']) + }), + timeZone: z.any() + }), + type: z.literal('dateTime') + }) + ]) + .describe( + 'This will always be a `date` or `dateTime` field config.' + ) + .optional() + }), + type: z.literal('createdTime') + }), + z.object({ + options: z.object({ + isValid: z + .boolean() + .describe( + '`false` when recordLinkFieldId is null, e.g. the referenced column was deleted.' + ), + recordLinkFieldId: z.union([z.string(), z.null()]).optional() + }), + type: z.literal('count') + }), + z.any(), + z.object({ + options: z.object({ + isValid: z + .boolean() + .describe( + 'False if this formula/field configuation has an error' + ), + referencedFieldIds: z + .union([z.array(z.string()), z.null()]) + .describe('The fields to check the last modified time of'), + result: z + .union([ + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + '`format` is always provided when reading.\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ), + name: z.enum([ + 'local', + 'friendly', + 'us', + 'european', + 'iso' + ]) + }) + }), + type: z.literal('date') + }), + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + '`format` is always provided when reading.\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ), + name: z.enum([ + 'local', + 'friendly', + 'us', + 'european', + 'iso' + ]) + }), + timeFormat: z.object({ + format: z.enum(['h:mma', 'HH:mm']), + name: z.enum(['12hour', '24hour']) + }), + timeZone: z.any() + }), + type: z.literal('dateTime') + }), + z.null() + ]) + .describe( + 'This will always be a `date` or `dateTime` field config.' + ) + }), + type: z.literal('lastModifiedTime') + }), + z.object({ type: z.literal('lastModifiedBy') }), + z.object({ + options: z.object({ + fieldIdInLinkedTable: z + .union([z.string(), z.null()]) + .describe( + 'The field in the linked table that this field is looking up.' + ), + isValid: z + .boolean() + .describe( + 'Is the field currently valid (e.g. false if the linked record field has\nbeen deleted)' + ), + recordLinkFieldId: z + .union([z.string(), z.null()]) + .describe('The linked record field in the current table.'), + result: z + .union([z.any(), z.null()]) + .describe( + 'The field type and options inside of the linked table. See other field\ntype configs on this page for the possible values. Can be null if invalid.' + ) + }), + type: z.literal('lookup') + }), + z.object({ + options: z.object({ + precision: z + .number() + .describe( + 'Indicates the number of digits shown to the right of the decimal point for this field. (0-8 inclusive)' + ) + }), + type: z.literal('number') + }), + z.object({ + options: z.object({ + precision: z + .number() + .describe( + 'Indicates the number of digits shown to the right of the decimal point for this field. (0-8 inclusive)' + ) + }), + type: z.literal('percent') + }), + z.object({ + options: z.object({ + precision: z + .number() + .describe( + 'Indicates the number of digits shown to the right of the decimal point for this field. (0-7 inclusive)' + ), + symbol: z.string().describe('Currency symbol to use.') + }), + type: z.literal('currency') + }), + z.object({ + options: z.object({ + durationFormat: z.enum([ + 'h:mm', + 'h:mm:ss', + 'h:mm:ss.S', + 'h:mm:ss.SS', + 'h:mm:ss.SSS' + ]) + }), + type: z.literal('duration') + }), + z.object({ type: z.literal('multilineText') }), + z.object({ type: z.literal('phoneNumber') }), + z + .object({ + options: z.object({ + color: z + .enum([ + 'yellowBright', + 'orangeBright', + 'redBright', + 'pinkBright', + 'purpleBright', + 'blueBright', + 'cyanBright', + 'tealBright', + 'greenBright', + 'grayBright' + ]) + .describe('The color of selected icons.'), + icon: z + .enum(['star', 'heart', 'thumbsUp', 'flag', 'dot']) + .describe('The icon name used to display the rating.'), + max: z + .number() + .describe( + 'The maximum value for the rating, from 1 to 10 inclusive.' + ) + }), + type: z.literal('rating') + }) + .describe( + "Bases on a free or plus plan are limited to using the 'star' icon and 'yellowBright' color." + ), + z.object({ type: z.literal('richText') }), + z.object({ + options: z.object({ + fieldIdInLinkedTable: z + .string() + .describe('The id of the field in the linked table') + .optional(), + isValid: z.boolean().optional(), + recordLinkFieldId: z + .string() + .describe('The linked field id') + .optional(), + referencedFieldIds: z + .array(z.string()) + .describe( + 'The ids of any fields referenced in the rollup formula' + ) + .optional(), + result: z + .union([z.any(), z.null()]) + .describe( + 'The resulting field type and options for the rollup. See other field\ntype configs on this page for the possible values. Can be null if invalid.' + ) + .optional() + }), + type: z.literal('rollup') + }), + z.object({ type: z.literal('singleLineText') }), + z.object({ type: z.literal('email') }), + z.object({ type: z.literal('url') }), + z.object({ + options: z.object({ + choices: z.array( + z.object({ + color: z + .string() + .describe( + 'Optional when the select field is configured to not use colors.\n\nAllowed values: "blueLight2", "cyanLight2", "tealLight2", "greenLight2", "yellowLight2", "orangeLight2", "redLight2", "pinkLight2", "purpleLight2", "grayLight2", "blueLight1", "cyanLight1", "tealLight1", "greenLight1", "yellowLight1", "orangeLight1", "redLight1", "pinkLight1", "purpleLight1", "grayLight1", "blueBright", "cyanBright", "tealBright", "greenBright", "yellowBright", "orangeBright", "redBright", "pinkBright", "purpleBright", "grayBright", "blueDark1", "cyanDark1", "tealDark1", "greenDark1", "yellowDark1", "orangeDark1", "redDark1", "pinkDark1", "purpleDark1", "grayDark1"' + ) + .optional(), + id: z.string(), + name: z.string() + }) + ) + }), + type: z.literal('externalSyncSource') + }), + z.object({ + options: z.object({ + prompt: z + .array( + z.union([ + z.string(), + z.object({ field: z.object({ fieldId: z.string() }) }) + ]) + ) + .describe( + 'The prompt that is used to generate the results in the AI field, additional object\ntypes may be added in the future. Currently, this is an array of strings or objects that identify any fields interpolated into the prompt.\n\nThe prompt will not currently be provided if this field config is within another\nfields configuration (like a lookup field)' + ) + .optional(), + referencedFieldIds: z + .array(z.string()) + .describe( + 'The other fields in the record that are used in the ai field\n\nThe referencedFieldIds will not currently be provided if this field config is within another\nfields configuration (like a lookup field)' + ) + .optional() + }), + type: z.literal('aiText') + }), + z + .object({ + options: z.object({ + linkedTableId: z + .string() + .describe('The ID of the table this field links to'), + viewIdForRecordSelection: z + .string() + .describe( + 'The ID of the view in the linked table\nto use when showing a list of records to select from' + ) + .optional() + }), + type: z.literal('multipleRecordLinks') + }) + .describe( + 'Creating "multipleRecordLinks" fields is supported but updating options for\nexisting "multipleRecordLinks" fields is not supported.' + ), + z.object({ + options: z.object({ + choices: z.array( + z.object({ + color: z + .string() + .describe('Optional when creating an option.') + .optional(), + id: z + .string() + .describe( + 'This is not specified when creating new options, useful when specifing existing\noptions (for example: reordering options, keeping old options and adding new ones, etc)' + ) + .optional(), + name: z.string() + }) + ) + }), + type: z.literal('singleSelect') + }), + z.object({ + options: z.object({ + choices: z.array( + z.object({ + color: z + .string() + .describe('Optional when creating an option.') + .optional(), + id: z + .string() + .describe( + 'This is not specified when creating new options, useful when specifing existing\noptions (for example: reordering options, keeping old options and adding new ones, etc)' + ) + .optional(), + name: z.string() + }) + ) + }), + type: z.literal('multipleSelects') + }), + z.object({ + options: z.record(z.any()).optional(), + type: z.literal('singleCollaborator') + }), + z.object({ + options: z.record(z.any()).optional(), + type: z.literal('multipleCollaborators') + }), + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + 'Format is optional when writing, but it must match\nthe corresponding name if provided.\n\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ) + .optional(), + name: z.enum(['local', 'friendly', 'us', 'european', 'iso']) + }) + }), + type: z.literal('date') + }), + z.object({ + options: z.object({ + dateFormat: z.object({ + format: z + .enum(['l', 'LL', 'M/D/YYYY', 'D/M/YYYY', 'YYYY-MM-DD']) + .describe( + 'Format is optional when writing, but it must match\nthe corresponding name if provided.\n\n(`l` for local, `LL` for friendly, `M/D/YYYY` for us, `D/M/YYYY` for european, `YYYY-MM-DD` for iso)' + ) + .optional(), + name: z.enum(['local', 'friendly', 'us', 'european', 'iso']) + }), + timeFormat: z.object({ + format: z.enum(['h:mma', 'HH:mm']).optional(), + name: z.enum(['12hour', '24hour']) + }), + timeZone: z.any() + }), + type: z.literal('dateTime') + }), + z.object({ + options: z.object({ isReversed: z.boolean() }).optional(), + type: z.literal('multipleAttachments') + }) + ]) + ) + .describe( + 'The config of a field. NB: Formula fields cannot be created with this MCP due to a limitation in the Airtable API.' + ) + export type Field = z.infer & { id: string } + + export const ViewSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string() + }) + export type View = z.infer + + export const TableSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + primaryFieldId: z.string(), + fields: z.array(FieldSchema.and(z.object({ id: z.string() }))), + views: z.array(ViewSchema) + }) + export type Table = z.infer + + export const BaseSchemaResponseSchema = z.object({ + tables: z.array(TableSchema) + }) + export type BaseSchemaResponse = z.infer + + // Zod schemas for tool arguments + export const ListRecordsArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + offset: z + .number() + .optional() + .describe('Offset to start the list from. Defaults to 0.'), + maxRecords: z + .number() + .optional() + .describe('Maximum number of records to return. Defaults to 100.'), + filterByFormula: z + .string() + .optional() + .describe('Airtable formula to filter records') + }) + export type ListRecordsArgs = z.infer + + export const SearchRecordsArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + searchTerm: z.string().describe('Text to search for in records'), + fieldIds: z + .array(z.string()) + .optional() + .describe( + 'Specific field ids to search in. If not provided, searches all text-based fields.' + ), + maxRecords: z + .number() + .optional() + .describe('Maximum number of records to return. Defaults to 100.') + }) + export type SearchRecordsArgs = z.infer + + export const TableDetailLevelSchema = z.enum([ + 'tableIdentifiersOnly', + 'identifiersOnly', + 'full' + ]).describe(`Detail level for table information: + - tableIdentifiersOnly: table IDs and names + - identifiersOnly: table, field, and view IDs and names + - full: complete details including field types, descriptions, and configurations + + Note for LLMs: To optimize context window usage, request the minimum detail level needed: + - Use 'tableIdentifiersOnly' when you only need to list or reference tables + - Use 'identifiersOnly' when you need to work with field or view references + - Only use 'full' when you need field types, descriptions, or other detailed configuration + + If you only need detailed information on a few tables in a base with many complex tables, it might be more efficient for you to use list_tables with tableIdentifiersOnly, then describe_table with full on the specific tables you want.`) + export type TableDetailLevel = z.infer + + export const DescribeTableArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + detailLevel: TableDetailLevelSchema.optional().default('full') + }) + export type DescribeTableArgs = z.infer + + export const ListTablesArgsSchema = z.object({ + baseId: z.string(), + detailLevel: TableDetailLevelSchema.optional().default('full') + }) + export type ListTablesArgs = z.infer + + export const GetRecordArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + recordId: z.string() + }) + export type GetRecordArgs = z.infer + + export const CreateRecordArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + fields: z.record(z.any()) + }) + export type CreateRecordArgs = z.infer + + export const UpdateRecordsArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + records: z.array( + z.object({ + id: z.string(), + fields: z.record(z.any()) + }) + ) + }) + export type UpdateRecordsArgs = z.infer + + export const DeleteRecordsArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + recordIds: z.array(z.string()) + }) + export type DeleteRecordsArgs = z.infer + + export const CreateTableArgsSchema = z.object({ + baseId: z.string(), + name: z + .string() + .describe('Name for the new table. Must be unique in the base.'), + description: z.string().optional(), + fields: z.array(FieldSchema).describe(`Table fields. Rules: + - At least one field must be specified. + - The primary (first) field must be one of: single line text, long text, date, phone number, email, URL, number, currency, percent, duration, formula, autonumber, barcode.`) + }) + export type CreateTableArgs = z.infer + + export const UpdateTableArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + name: z.string().optional(), + description: z.string().optional() + }) + export type UpdateTableArgs = z.infer + + export const CreateFieldArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + // This is used as a workaround for https://github.com/orgs/modelcontextprotocol/discussions/90 + body: z.object({ + field: FieldSchema + }) + }) + export type CreateFieldArgs = z.infer + + export const UpdateFieldArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + fieldId: z.string(), + name: z.string().optional(), + description: z.string().optional() + }) + export type UpdateFieldArgs = z.infer + + export type FieldSet = Record + export type AirtableRecord = { id: string; fields: FieldSet } + + export type AirtableTableToDetailLevel< + TDetailLevel extends TableDetailLevel | undefined = 'full' + > = TDetailLevel extends undefined + ? airtable.Table + : TDetailLevel extends 'full' + ? airtable.Table + : TDetailLevel extends 'identifiersOnly' + ? { + id: string + name: string + fields: { id: string; name: string }[] + views: { id: string; name: string }[] + } + : TDetailLevel extends 'tableIdentifiersOnly' + ? { id: string; name: string } + : never +} diff --git a/packages/airtable/src/index.ts b/packages/airtable/src/index.ts new file mode 100644 index 00000000..2b3f8440 --- /dev/null +++ b/packages/airtable/src/index.ts @@ -0,0 +1,2 @@ +export * from './airtable' +export * from './airtable-client' diff --git a/packages/airtable/tsconfig.json b/packages/airtable/tsconfig.json new file mode 100644 index 00000000..51348fa1 --- /dev/null +++ b/packages/airtable/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@fisch0920/config/tsconfig-node", + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/open-meteo/src/open-meteo-client.ts b/packages/open-meteo/src/open-meteo-client.ts index 3117b9df..50a5912c 100644 --- a/packages/open-meteo/src/open-meteo-client.ts +++ b/packages/open-meteo/src/open-meteo-client.ts @@ -28,10 +28,12 @@ export class OpenMeteoClient extends AIFunctionsProvider { constructor({ apiKey = getEnv('OPEN_METEO_API_KEY'), apiBaseUrl = openmeteo.apiBaseUrl, + timeoutMs = 60_000, ky = defaultKy }: { apiKey?: string apiBaseUrl?: string + timeoutMs?: number ky?: KyInstance } = {}) { super() @@ -41,6 +43,7 @@ export class OpenMeteoClient extends AIFunctionsProvider { this.ky = ky.extend({ prefixUrl: apiBaseUrl, + timeout: timeoutMs, ...(apiKey ? { headers: { diff --git a/packages/reddit/src/reddit-client.ts b/packages/reddit/src/reddit-client.ts index 92fdfa97..55497b22 100644 --- a/packages/reddit/src/reddit-client.ts +++ b/packages/reddit/src/reddit-client.ts @@ -320,10 +320,12 @@ export class RedditClient extends AIFunctionsProvider { constructor({ baseUrl = reddit.BASE_URL, userAgent = 'agentic-reddit-client/1.0.0', + timeoutMs = 60_000, ky = defaultKy }: { baseUrl?: string userAgent?: string + timeoutMs?: number ky?: KyInstance } = {}) { super() @@ -332,6 +334,7 @@ export class RedditClient extends AIFunctionsProvider { this.ky = ky.extend({ prefixUrl: this.baseUrl, + timeout: timeoutMs, headers: { 'User-Agent': userAgent } diff --git a/packages/stdlib/package.json b/packages/stdlib/package.json index 80b333b3..1845707b 100644 --- a/packages/stdlib/package.json +++ b/packages/stdlib/package.json @@ -32,6 +32,7 @@ "test:typecheck": "tsc --noEmit" }, "dependencies": { + "@agentic/airtable": "workspace:*", "@agentic/apollo": "workspace:*", "@agentic/arxiv": "workspace:*", "@agentic/bing": "workspace:*", diff --git a/packages/stdlib/src/index.ts b/packages/stdlib/src/index.ts index fc7a6cbe..b8cd4e0a 100644 --- a/packages/stdlib/src/index.ts +++ b/packages/stdlib/src/index.ts @@ -1,3 +1,4 @@ +export * from '@agentic/airtable' export * from '@agentic/apollo' export * from '@agentic/arxiv' export * from '@agentic/bing' diff --git a/packages/typeform/src/typeform-client.ts b/packages/typeform/src/typeform-client.ts index 0cb0f2f0..29747e8d 100644 --- a/packages/typeform/src/typeform-client.ts +++ b/packages/typeform/src/typeform-client.ts @@ -98,11 +98,13 @@ export class TypeformClient extends AIFunctionsProvider { constructor({ apiKey = getEnv('TYPEFORM_API_KEY'), apiBaseUrl = typeform.API_BASE_URL, + timeoutMs = 60_000, ky = defaultKy }: { /** Typeform Personal Access Token */ apiKey?: string apiBaseUrl?: string + timeoutMs?: number ky?: KyInstance } = {}) { assert( @@ -116,6 +118,7 @@ export class TypeformClient extends AIFunctionsProvider { this.ky = ky.extend({ prefixUrl: this.apiBaseUrl, + timeout: timeoutMs, headers: { Authorization: `Bearer ${this.apiKey}` } diff --git a/packages/youtube/src/youtube-client.ts b/packages/youtube/src/youtube-client.ts index ca422655..e285dd15 100644 --- a/packages/youtube/src/youtube-client.ts +++ b/packages/youtube/src/youtube-client.ts @@ -97,10 +97,12 @@ export class YouTubeClient extends AIFunctionsProvider { constructor({ apiKey = getEnv('YOUTUBE_API_KEY'), apiBaseUrl = youtube.API_BASE_URL, + timeoutMs = 30_000, ky = defaultKy }: { apiKey?: string apiBaseUrl?: string + timeoutMs?: number ky?: KyInstance } = {}) { assert( @@ -113,7 +115,8 @@ export class YouTubeClient extends AIFunctionsProvider { this.apiBaseUrl = apiBaseUrl this.ky = ky.extend({ - prefixUrl: this.apiBaseUrl + prefixUrl: this.apiBaseUrl, + timeout: timeoutMs }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2139c12d..564394bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,6 +445,21 @@ importers: specifier: 'catalog:' version: 4.3.4(react@18.3.1)(zod@3.24.2) + packages/airtable: + dependencies: + '@agentic/core': + specifier: workspace:* + version: link:../core + ky: + specifier: 'catalog:' + version: 1.8.0 + p-throttle: + specifier: 'catalog:' + version: 6.2.0 + zod: + specifier: 'catalog:' + version: 3.24.2 + packages/apollo: dependencies: '@agentic/core': @@ -1071,6 +1086,9 @@ importers: packages/stdlib: dependencies: + '@agentic/airtable': + specifier: workspace:* + version: link:../airtable '@agentic/apollo': specifier: workspace:* version: link:../apollo diff --git a/readme.md b/readme.md index 5c1308ce..f1741957 100644 --- a/readme.md +++ b/readme.md @@ -180,6 +180,7 @@ Full docs are available at [agentic.so](https://agentic.so). | Service / Tool | Package | Docs | Description | | ------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Airtable](https://airtable.com/developers/web/api/introduction) | `@agentic/airtable` | [docs](https://agentic.so/tools/airtable) | No-code spreadsheets, CRM, and database. | | [Apollo](https://docs.apollo.io) | `@agentic/apollo` | [docs](https://agentic.so/tools/apollo) | B2B person and company enrichment API. | | [ArXiv](https://arxiv.org) | `@agentic/arxiv` | [docs](https://agentic.so/tools/arxiv) | Search for research articles. | | [Bing](https://www.microsoft.com/en-us/bing/apis/bing-web-search-api) | `@agentic/bing` | [docs](https://agentic.so/tools/bing) | Bing web search. |