diff --git a/README.md b/README.md
index 2195ed6..448858e 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ The current `golemcore/*` modules in this repository are:
| Plugin | Purpose |
| --- | --- |
+| `golemcore/airtable` | Airtable records tool plugin with configurable read/write operations for bases and tables. |
| `golemcore/brave-search` | Brave Search web search tool plugin with API-key backed queries. |
| `golemcore/browser` | Playwright-backed browser automation tool with screenshot support. |
| `golemcore/browserless` | Browserless smart scrape plugin for rendered HTML, markdown, and link extraction. |
diff --git a/golemcore/airtable/plugin.yaml b/golemcore/airtable/plugin.yaml
new file mode 100644
index 0000000..4ffc970
--- /dev/null
+++ b/golemcore/airtable/plugin.yaml
@@ -0,0 +1,12 @@
+id: golemcore/airtable
+provider: golemcore
+name: airtable
+version: 1.0.0
+pluginApiVersion: 1
+engineVersion: ">=0.0.0 <1.0.0"
+entrypoint: me.golemcore.plugins.golemcore.airtable.AirtablePluginBootstrap
+description: Airtable records tool plugin backed by the Airtable REST API.
+sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/airtable
+license: Apache-2.0
+maintainers:
+ - alexk-dev
diff --git a/golemcore/airtable/pom.xml b/golemcore/airtable/pom.xml
new file mode 100644
index 0000000..025329f
--- /dev/null
+++ b/golemcore/airtable/pom.xml
@@ -0,0 +1,104 @@
+
+
+ 4.0.0
+
+
+ me.golemcore.plugins
+ golemcore-plugins
+ 1.0.0
+ ../../pom.xml
+
+
+ 1.0.0
+ golemcore-airtable-plugin
+ golemcore/airtable
+ Airtable records tool plugin for GolemCore
+
+
+ golemcore
+ airtable
+ ../../misc/formatter_eclipse.xml
+
+
+
+
+ me.golemcore.plugins
+ golemcore-plugin-extension-api
+ provided
+
+
+ me.golemcore.plugins
+ golemcore-plugin-runtime-api
+ provided
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ org.springframework
+ spring-context
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.squareup.okhttp3
+ okhttp-jvm
+ ${okhttp.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ src/main/resources
+
+
+ ${project.basedir}
+
+ plugin.yaml
+
+ META-INF/golemcore
+
+
+
+
+ net.revelc.code.formatter
+ formatter-maven-plugin
+
+
+ maven-shade-plugin
+
+
+ maven-antrun-plugin
+
+
+ copy-plugin-artifact
+ package
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java
new file mode 100644
index 0000000..d194540
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java
@@ -0,0 +1,22 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import me.golemcore.plugin.api.extension.spi.PluginBootstrap;
+import me.golemcore.plugin.api.extension.spi.PluginDescriptor;
+
+public class AirtablePluginBootstrap implements PluginBootstrap {
+
+ @Override
+ public PluginDescriptor descriptor() {
+ return PluginDescriptor.builder()
+ .id("golemcore/airtable")
+ .provider("golemcore")
+ .name("airtable")
+ .entrypoint(getClass().getName())
+ .build();
+ }
+
+ @Override
+ public Class> configurationClass() {
+ return AirtablePluginConfiguration.class;
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java
new file mode 100644
index 0000000..fdf7e0f
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java
@@ -0,0 +1,86 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class AirtablePluginConfig {
+
+ static final String DEFAULT_API_BASE_URL = "https://api.airtable.com";
+ static final int DEFAULT_MAX_RECORDS = 20;
+ static final int MAX_RECORDS_LIMIT = 100;
+
+ @Builder.Default
+ private Boolean enabled = false;
+
+ @Builder.Default
+ private String apiBaseUrl = DEFAULT_API_BASE_URL;
+
+ private String apiToken;
+ private String baseId;
+
+ @Builder.Default
+ private String defaultTable = "";
+
+ @Builder.Default
+ private String defaultView = "";
+
+ @Builder.Default
+ private Integer defaultMaxRecords = DEFAULT_MAX_RECORDS;
+
+ @Builder.Default
+ private Boolean allowWrite = false;
+
+ @Builder.Default
+ private Boolean allowDelete = false;
+
+ @Builder.Default
+ private Boolean typecast = false;
+
+ public void normalize() {
+ if (enabled == null) {
+ enabled = false;
+ }
+ apiBaseUrl = normalizeUrl(apiBaseUrl, DEFAULT_API_BASE_URL);
+ apiToken = normalizeText(apiToken, null);
+ baseId = normalizeText(baseId, null);
+ defaultTable = normalizeText(defaultTable, "");
+ defaultView = normalizeText(defaultView, "");
+ if (defaultMaxRecords == null || defaultMaxRecords <= 0) {
+ defaultMaxRecords = DEFAULT_MAX_RECORDS;
+ }
+ if (defaultMaxRecords > MAX_RECORDS_LIMIT) {
+ defaultMaxRecords = MAX_RECORDS_LIMIT;
+ }
+ if (allowWrite == null) {
+ allowWrite = false;
+ }
+ if (allowDelete == null) {
+ allowDelete = false;
+ }
+ if (typecast == null) {
+ typecast = false;
+ }
+ }
+
+ private String normalizeUrl(String value, String defaultValue) {
+ String trimmed = normalizeText(value, defaultValue);
+ while (trimmed.endsWith("/")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ return trimmed.isBlank() ? defaultValue : trimmed;
+ }
+
+ private String normalizeText(String value, String defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? defaultValue : trimmed;
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java
new file mode 100644
index 0000000..293539f
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java
@@ -0,0 +1,36 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import me.golemcore.plugin.api.runtime.PluginConfigurationService;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+public class AirtablePluginConfigService {
+
+ static final String PLUGIN_ID = "golemcore/airtable";
+
+ private final PluginConfigurationService pluginConfigurationService;
+ private final ObjectMapper objectMapper;
+
+ public AirtablePluginConfigService(PluginConfigurationService pluginConfigurationService) {
+ this.pluginConfigurationService = pluginConfigurationService;
+ this.objectMapper = new ObjectMapper();
+ }
+
+ public AirtablePluginConfig getConfig() {
+ Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID);
+ AirtablePluginConfig config = raw.isEmpty()
+ ? AirtablePluginConfig.builder().build()
+ : objectMapper.convertValue(raw, AirtablePluginConfig.class);
+ config.normalize();
+ return config;
+ }
+
+ @SuppressWarnings("unchecked")
+ public void save(AirtablePluginConfig config) {
+ config.normalize();
+ pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class));
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java
new file mode 100644
index 0000000..1e662d6
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java
@@ -0,0 +1,12 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration(proxyBeanMethods = false)
+@ComponentScan(basePackageClasses = {
+ AirtableRecordsToolProvider.class,
+ AirtablePluginSettingsContributor.class
+})
+public class AirtablePluginConfiguration {
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java
new file mode 100644
index 0000000..2f98c10
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java
@@ -0,0 +1,261 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import me.golemcore.plugin.api.extension.spi.PluginActionResult;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsAction;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsField;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsSection;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableApiClient;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableApiException;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableTransportException;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class AirtablePluginSettingsContributor implements PluginSettingsContributor {
+
+ private static final String SECTION_KEY = "main";
+ private static final String ACTION_TEST_CONNECTION = "test-connection";
+
+ private final AirtablePluginConfigService configService;
+ private final AirtableApiClient apiClient;
+
+ public AirtablePluginSettingsContributor(
+ AirtablePluginConfigService configService,
+ AirtableApiClient apiClient) {
+ this.configService = configService;
+ this.apiClient = apiClient;
+ }
+
+ @Override
+ public String getPluginId() {
+ return AirtablePluginConfigService.PLUGIN_ID;
+ }
+
+ @Override
+ public List getCatalogItems() {
+ return List.of(PluginSettingsCatalogItem.builder()
+ .pluginId(AirtablePluginConfigService.PLUGIN_ID)
+ .pluginName("airtable")
+ .provider("golemcore")
+ .sectionKey(SECTION_KEY)
+ .title("Airtable")
+ .description("Airtable base, default table, and write permissions for records operations.")
+ .blockKey("tools")
+ .blockTitle("Tools")
+ .blockDescription("Tool-specific runtime behavior and integrations")
+ .order(30)
+ .build());
+ }
+
+ @Override
+ public PluginSettingsSection getSection(String sectionKey) {
+ requireSection(sectionKey);
+ AirtablePluginConfig config = configService.getConfig();
+ Map values = new LinkedHashMap<>();
+ values.put("enabled", Boolean.TRUE.equals(config.getEnabled()));
+ values.put("apiBaseUrl", config.getApiBaseUrl());
+ values.put("apiToken", "");
+ values.put("baseId", orEmpty(config.getBaseId()));
+ values.put("defaultTable", config.getDefaultTable());
+ values.put("defaultView", config.getDefaultView());
+ values.put("defaultMaxRecords", config.getDefaultMaxRecords());
+ values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite()));
+ values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete()));
+ values.put("typecast", Boolean.TRUE.equals(config.getTypecast()));
+ return PluginSettingsSection.builder()
+ .title("Airtable")
+ .description("Configure Airtable API access and the default table used by the airtable_records tool.")
+ .fields(List.of(
+ booleanField("enabled", "Enable Airtable",
+ "Allow the model to list and mutate Airtable records."),
+ textField("apiBaseUrl", "API Base URL",
+ "Airtable REST API base URL.", AirtablePluginConfig.DEFAULT_API_BASE_URL),
+ secretField("apiToken", "API Token",
+ "Personal access token. Leave blank to keep the current secret."),
+ textField("baseId", "Base ID",
+ "Airtable base identifier used for requests.", "appXXXXXXXXXXXXXX"),
+ textField("defaultTable", "Default Table",
+ "Optional default table name or ID used when the tool call omits table.",
+ "Tasks"),
+ textField("defaultView", "Default View",
+ "Optional default Airtable view used for list operations.", "Grid view"),
+ numberField("defaultMaxRecords", "Default Max Records",
+ "Maximum records returned when max_records is omitted.", 1.0, 100.0, 1.0),
+ booleanField("allowWrite", "Allow Write",
+ "Permit create_record and update_record operations."),
+ booleanField("allowDelete", "Allow Delete",
+ "Permit delete_record operations."),
+ booleanField("typecast", "Enable Typecast",
+ "Use Airtable typecast for create and update operations by default.")))
+ .values(values)
+ .actions(List.of(PluginSettingsAction.builder()
+ .actionId(ACTION_TEST_CONNECTION)
+ .label("Test Connection")
+ .variant("secondary")
+ .build()))
+ .build();
+ }
+
+ @Override
+ public PluginSettingsSection saveSection(String sectionKey, Map values) {
+ requireSection(sectionKey);
+ AirtablePluginConfig config = configService.getConfig();
+ config.setEnabled(readBoolean(values, "enabled", false));
+ config.setApiBaseUrl(readString(values, "apiBaseUrl", config.getApiBaseUrl()));
+ String apiToken = readString(values, "apiToken", null);
+ if (apiToken != null && !apiToken.isBlank()) {
+ config.setApiToken(apiToken);
+ }
+ config.setBaseId(readString(values, "baseId", config.getBaseId()));
+ config.setDefaultTable(readString(values, "defaultTable", config.getDefaultTable()));
+ config.setDefaultView(readString(values, "defaultView", config.getDefaultView()));
+ config.setDefaultMaxRecords(readInteger(values, "defaultMaxRecords", config.getDefaultMaxRecords()));
+ config.setAllowWrite(readBoolean(values, "allowWrite", false));
+ config.setAllowDelete(readBoolean(values, "allowDelete", false));
+ config.setTypecast(readBoolean(values, "typecast", false));
+ configService.save(config);
+ return getSection(sectionKey);
+ }
+
+ @Override
+ public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) {
+ requireSection(sectionKey);
+ if (ACTION_TEST_CONNECTION.equals(actionId)) {
+ return testConnection();
+ }
+ throw new IllegalArgumentException("Unknown Airtable action: " + actionId);
+ }
+
+ private PluginActionResult testConnection() {
+ AirtablePluginConfig config = configService.getConfig();
+ if (!hasText(config.getApiToken())) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("Airtable API token is not configured.")
+ .build();
+ }
+ if (!hasText(config.getBaseId())) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("Airtable base ID is not configured.")
+ .build();
+ }
+ if (!hasText(config.getDefaultTable())) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("Default Airtable table is not configured.")
+ .build();
+ }
+ try {
+ AirtableApiClient.AirtableListResponse response = apiClient.listRecords(
+ config.getDefaultTable(),
+ config.getDefaultView(),
+ null,
+ 1,
+ List.of(),
+ null,
+ null);
+ return PluginActionResult.builder()
+ .status("ok")
+ .message("Connected to Airtable. Read access to table "
+ + config.getDefaultTable() + " is available ("
+ + response.records().size() + " record(s) checked).")
+ .build();
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("Connection failed: " + ex.getMessage())
+ .build();
+ }
+ }
+
+ private PluginSettingsField booleanField(String key, String label, String description) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("boolean")
+ .label(label)
+ .description(description)
+ .build();
+ }
+
+ private PluginSettingsField textField(String key, String label, String description, String placeholder) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("text")
+ .label(label)
+ .description(description)
+ .placeholder(placeholder)
+ .build();
+ }
+
+ private PluginSettingsField secretField(String key, String label, String description) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("secret")
+ .label(label)
+ .description(description)
+ .build();
+ }
+
+ private PluginSettingsField numberField(String key, String label, String description,
+ Double min, Double max, Double step) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("number")
+ .label(label)
+ .description(description)
+ .min(min)
+ .max(max)
+ .step(step)
+ .build();
+ }
+
+ private void requireSection(String sectionKey) {
+ if (!SECTION_KEY.equals(sectionKey)) {
+ throw new IllegalArgumentException("Unknown Airtable settings section: " + sectionKey);
+ }
+ }
+
+ private boolean readBoolean(Map values, String key, boolean defaultValue) {
+ Object value = values.get(key);
+ return value instanceof Boolean bool ? bool : defaultValue;
+ }
+
+ private int readInteger(Map values, String key, int defaultValue) {
+ Object value = values.get(key);
+ if (value instanceof Number number) {
+ return number.intValue();
+ }
+ if (value instanceof String text && !text.isBlank()) {
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException ignored) {
+ return defaultValue;
+ }
+ }
+ return defaultValue;
+ }
+
+ private String readString(Map values, String key, String defaultValue) {
+ Object value = values.get(key);
+ if (value instanceof String text) {
+ String trimmed = text.trim();
+ return trimmed.isEmpty() ? defaultValue : trimmed;
+ }
+ return defaultValue;
+ }
+
+ private String orEmpty(String value) {
+ return value != null ? value : "";
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java
new file mode 100644
index 0000000..42cdf4e
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java
@@ -0,0 +1,304 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableApiClient;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableApiException;
+import me.golemcore.plugins.golemcore.airtable.support.AirtableTransportException;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+@Service
+public class AirtableRecordsService {
+
+ private final AirtableApiClient apiClient;
+ private final AirtablePluginConfigService configService;
+ private final ObjectMapper objectMapper;
+
+ public AirtableRecordsService(AirtableApiClient apiClient, AirtablePluginConfigService configService) {
+ this.apiClient = apiClient;
+ this.configService = configService;
+ this.objectMapper = new ObjectMapper();
+ }
+
+ public AirtablePluginConfig getConfig() {
+ return configService.getConfig();
+ }
+
+ public ToolResult listRecords(
+ String table,
+ String view,
+ String filterByFormula,
+ Integer maxRecords,
+ List fieldNames,
+ String sortField,
+ String sortDirection) {
+ try {
+ AirtablePluginConfig config = requireConfigured();
+ String resolvedTable = resolveTable(table, config);
+ String resolvedView = hasText(view) ? view.trim() : config.getDefaultView();
+ int resolvedMaxRecords = normalizeMaxRecords(maxRecords, config.getDefaultMaxRecords());
+ List normalizedFieldNames = normalizeFieldNames(fieldNames);
+ String normalizedSortField = normalizeOptional(sortField);
+ String normalizedSortDirection = normalizeSortDirection(sortDirection);
+ AirtableApiClient.AirtableListResponse response = apiClient.listRecords(
+ resolvedTable,
+ resolvedView,
+ normalizeOptional(filterByFormula),
+ resolvedMaxRecords,
+ normalizedFieldNames,
+ normalizedSortField,
+ normalizedSortDirection);
+
+ Map data = new LinkedHashMap<>();
+ data.put("table", resolvedTable);
+ data.put("count", response.records().size());
+ if (hasText(response.offset())) {
+ data.put("next_offset", response.offset());
+ }
+ data.put("records", response.records().stream().map(this::toRecordMap).toList());
+ return ToolResult.success(buildListOutput(resolvedTable, resolvedView, response), data);
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult getRecord(String table, String recordId) {
+ try {
+ AirtablePluginConfig config = requireConfigured();
+ String resolvedTable = resolveTable(table, config);
+ String resolvedRecordId = requireRecordId(recordId);
+ AirtableApiClient.AirtableRecord record = apiClient.getRecord(resolvedTable, resolvedRecordId);
+ Map data = toRecordMap(record);
+ data.put("table", resolvedTable);
+ return ToolResult.success(
+ "Airtable record " + record.id() + " in " + resolvedTable + ":\n"
+ + serializeJson(record.fields()),
+ data);
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult createRecord(String table, Map fields, Optional typecastOverride) {
+ if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED,
+ "Airtable write is disabled in plugin settings");
+ }
+ try {
+ AirtablePluginConfig config = requireConfigured();
+ String resolvedTable = resolveTable(table, config);
+ Map normalizedFields = requireFields(fields);
+ boolean typecast = resolveTypecast(typecastOverride, config);
+ AirtableApiClient.AirtableRecord record = apiClient.createRecord(resolvedTable, normalizedFields, typecast);
+ Map data = toRecordMap(record);
+ data.put("table", resolvedTable);
+ return ToolResult.success(
+ "Created Airtable record " + record.id() + " in " + resolvedTable + ":\n"
+ + serializeJson(record.fields()),
+ data);
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult updateRecord(
+ String table,
+ String recordId,
+ Map fields,
+ Optional typecastOverride) {
+ if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED,
+ "Airtable write is disabled in plugin settings");
+ }
+ try {
+ AirtablePluginConfig config = requireConfigured();
+ String resolvedTable = resolveTable(table, config);
+ String resolvedRecordId = requireRecordId(recordId);
+ Map normalizedFields = requireFields(fields);
+ boolean typecast = resolveTypecast(typecastOverride, config);
+ AirtableApiClient.AirtableRecord record = apiClient.updateRecord(
+ resolvedTable,
+ resolvedRecordId,
+ normalizedFields,
+ typecast);
+ Map data = toRecordMap(record);
+ data.put("table", resolvedTable);
+ return ToolResult.success(
+ "Updated Airtable record " + record.id() + " in " + resolvedTable + ":\n"
+ + serializeJson(record.fields()),
+ data);
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult deleteRecord(String table, String recordId) {
+ if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED,
+ "Airtable delete is disabled in plugin settings");
+ }
+ try {
+ AirtablePluginConfig config = requireConfigured();
+ String resolvedTable = resolveTable(table, config);
+ String resolvedRecordId = requireRecordId(recordId);
+ AirtableApiClient.AirtableDeleteResponse response = apiClient.deleteRecord(resolvedTable, resolvedRecordId);
+ Map data = new LinkedHashMap<>();
+ data.put("table", resolvedTable);
+ data.put("record_id", response.id());
+ data.put("deleted", response.deleted());
+ return ToolResult.success(
+ "Deleted Airtable record " + response.id() + " from " + resolvedTable + '.',
+ data);
+ } catch (IllegalArgumentException | IllegalStateException | AirtableApiException
+ | AirtableTransportException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ private AirtablePluginConfig requireConfigured() {
+ AirtablePluginConfig config = configService.getConfig();
+ if (!Boolean.TRUE.equals(config.getEnabled())) {
+ throw new IllegalStateException("Airtable plugin is disabled in plugin settings");
+ }
+ if (!hasText(config.getApiToken())) {
+ throw new IllegalStateException("Airtable API token is not configured.");
+ }
+ if (!hasText(config.getBaseId())) {
+ throw new IllegalStateException("Airtable base ID is not configured.");
+ }
+ return config;
+ }
+
+ private String resolveTable(String table, AirtablePluginConfig config) {
+ String resolvedTable = hasText(table) ? table.trim() : config.getDefaultTable();
+ if (!hasText(resolvedTable)) {
+ throw new IllegalArgumentException(
+ "Airtable table is required. Configure a default table or pass the table parameter.");
+ }
+ return resolvedTable;
+ }
+
+ private String requireRecordId(String recordId) {
+ if (!hasText(recordId)) {
+ throw new IllegalArgumentException("record_id is required");
+ }
+ return recordId.trim();
+ }
+
+ private Map requireFields(Map fields) {
+ if (fields == null || fields.isEmpty()) {
+ throw new IllegalArgumentException("fields must contain at least one Airtable field");
+ }
+ return fields;
+ }
+
+ private int normalizeMaxRecords(Integer requestedMaxRecords, int defaultMaxRecords) {
+ int resolved = requestedMaxRecords != null ? requestedMaxRecords : defaultMaxRecords;
+ if (resolved <= 0) {
+ return AirtablePluginConfig.DEFAULT_MAX_RECORDS;
+ }
+ return Math.min(resolved, AirtablePluginConfig.MAX_RECORDS_LIMIT);
+ }
+
+ private List normalizeFieldNames(List fieldNames) {
+ List normalized = new ArrayList<>();
+ if (fieldNames == null) {
+ return normalized;
+ }
+ for (String fieldName : fieldNames) {
+ if (hasText(fieldName) && !normalized.contains(fieldName.trim())) {
+ normalized.add(fieldName.trim());
+ }
+ }
+ return normalized;
+ }
+
+ private String normalizeOptional(String value) {
+ return hasText(value) ? value.trim() : null;
+ }
+
+ private String normalizeSortDirection(String value) {
+ if (!hasText(value)) {
+ return null;
+ }
+ String normalized = value.trim().toLowerCase(Locale.ROOT);
+ if ("asc".equals(normalized) || "desc".equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("sort_direction must be asc or desc");
+ }
+
+ private boolean resolveTypecast(Optional typecastOverride, AirtablePluginConfig config) {
+ return typecastOverride.orElse(Boolean.TRUE.equals(config.getTypecast()));
+ }
+
+ private String buildListOutput(
+ String table,
+ String view,
+ AirtableApiClient.AirtableListResponse response) {
+ if (response.records().isEmpty()) {
+ return "No Airtable records found in " + table + '.';
+ }
+ StringBuilder output = new StringBuilder();
+ output.append("Airtable records from ")
+ .append(table)
+ .append(" (")
+ .append(response.records().size())
+ .append(" record(s)");
+ if (hasText(view)) {
+ output.append(", view=").append(view);
+ }
+ output.append("):\n\n");
+ for (int index = 0; index < response.records().size(); index++) {
+ AirtableApiClient.AirtableRecord record = response.records().get(index);
+ output.append(index + 1)
+ .append(". ")
+ .append(record.id())
+ .append('\n')
+ .append(serializeJson(record.fields()))
+ .append("\n\n");
+ }
+ if (hasText(response.offset())) {
+ output.append("More records are available. Next offset: ")
+ .append(response.offset());
+ }
+ return output.toString().trim();
+ }
+
+ private Map toRecordMap(AirtableApiClient.AirtableRecord record) {
+ Map data = new LinkedHashMap<>();
+ data.put("id", record.id());
+ data.put("created_time", record.createdTime());
+ data.put("fields", record.fields());
+ return data;
+ }
+
+ private String serializeJson(Object value) {
+ try {
+ return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
+ } catch (JsonProcessingException ex) {
+ return String.valueOf(value);
+ }
+ }
+
+ private ToolResult executionFailure(String message) {
+ return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message);
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java
new file mode 100644
index 0000000..4eb8a01
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java
@@ -0,0 +1,254 @@
+package me.golemcore.plugins.golemcore.airtable;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import me.golemcore.plugin.api.extension.model.ToolDefinition;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import me.golemcore.plugin.api.extension.spi.ToolProvider;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+public class AirtableRecordsToolProvider implements ToolProvider {
+
+ private static final String TYPE = "type";
+ private static final String TYPE_OBJECT = "object";
+ private static final String TYPE_STRING = "string";
+ private static final String TYPE_INTEGER = "integer";
+ private static final String TYPE_BOOLEAN = "boolean";
+ private static final String TYPE_ARRAY = "array";
+ private static final String PROPERTIES = "properties";
+ private static final String REQUIRED = "required";
+ private static final String ITEMS = "items";
+ private static final String PARAM_OPERATION = "operation";
+ private static final String PARAM_TABLE = "table";
+ private static final String PARAM_VIEW = "view";
+ private static final String PARAM_FILTER_BY_FORMULA = "filter_by_formula";
+ private static final String PARAM_MAX_RECORDS = "max_records";
+ private static final String PARAM_FIELD_NAMES = "field_names";
+ private static final String PARAM_SORT_FIELD = "sort_field";
+ private static final String PARAM_SORT_DIRECTION = "sort_direction";
+ private static final String PARAM_RECORD_ID = "record_id";
+ private static final String PARAM_FIELDS = "fields";
+ private static final String PARAM_TYPECAST = "typecast";
+
+ private final AirtableRecordsService service;
+ private final ObjectMapper objectMapper;
+
+ public AirtableRecordsToolProvider(AirtableRecordsService service) {
+ this.service = service;
+ this.objectMapper = new ObjectMapper();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ AirtablePluginConfig config = service.getConfig();
+ return Boolean.TRUE.equals(config.getEnabled())
+ && hasText(config.getApiToken())
+ && hasText(config.getBaseId());
+ }
+
+ @Override
+ public ToolDefinition getDefinition() {
+ return ToolDefinition.builder()
+ .name("airtable_records")
+ .description("List, read, create, update, and delete Airtable records through the Airtable REST API.")
+ .inputSchema(Map.ofEntries(
+ Map.entry(TYPE, TYPE_OBJECT),
+ Map.entry(PROPERTIES, Map.ofEntries(
+ Map.entry(PARAM_OPERATION, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("enum", List.of(
+ "list_records",
+ "get_record",
+ "create_record",
+ "update_record",
+ "delete_record")))),
+ Map.entry(PARAM_TABLE, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("description", "Optional Airtable table name or table ID."))),
+ Map.entry(PARAM_VIEW, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("description", "Optional Airtable view used for list_records."))),
+ Map.entry(PARAM_FILTER_BY_FORMULA, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("description",
+ "Optional Airtable filterByFormula value for list_records."))),
+ Map.entry(PARAM_MAX_RECORDS, Map.ofEntries(
+ Map.entry(TYPE, TYPE_INTEGER),
+ Map.entry("description",
+ "Maximum number of records to return for list_records (1-100)."))),
+ Map.entry(PARAM_FIELD_NAMES, Map.ofEntries(
+ Map.entry(TYPE, TYPE_ARRAY),
+ Map.entry(ITEMS, Map.of(TYPE, TYPE_STRING)),
+ Map.entry("description",
+ "Optional list of Airtable field names to include in list_records."))),
+ Map.entry(PARAM_SORT_FIELD, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("description",
+ "Optional Airtable field used for sorting list_records."))),
+ Map.entry(PARAM_SORT_DIRECTION, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("enum", List.of("asc", "desc")),
+ Map.entry("description", "Optional sort direction for list_records."))),
+ Map.entry(PARAM_RECORD_ID, Map.ofEntries(
+ Map.entry(TYPE, TYPE_STRING),
+ Map.entry("description",
+ "Airtable record ID for get_record, update_record, or delete_record."))),
+ Map.entry(PARAM_FIELDS, Map.ofEntries(
+ Map.entry(TYPE, TYPE_OBJECT),
+ Map.entry("description",
+ "Field map for create_record or update_record. A JSON string is also accepted for convenience."))),
+ Map.entry(PARAM_TYPECAST, Map.ofEntries(
+ Map.entry(TYPE, TYPE_BOOLEAN),
+ Map.entry("description",
+ "Optional override for Airtable typecast on create/update."))))),
+ Map.entry(REQUIRED, List.of(PARAM_OPERATION)),
+ Map.entry("allOf", List.of(
+ requiredWhen("get_record", List.of(PARAM_RECORD_ID)),
+ requiredWhen("create_record", List.of(PARAM_FIELDS)),
+ requiredWhen("update_record", List.of(PARAM_RECORD_ID, PARAM_FIELDS)),
+ requiredWhen("delete_record", List.of(PARAM_RECORD_ID))))))
+ .build();
+ }
+
+ @Override
+ public CompletableFuture execute(Map parameters) {
+ return CompletableFuture.supplyAsync(() -> executeOperation(parameters));
+ }
+
+ private ToolResult executeOperation(Map parameters) {
+ try {
+ String operation = readString(parameters.get(PARAM_OPERATION));
+ if (!hasText(operation)) {
+ return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required");
+ }
+ return switch (operation) {
+ case "list_records" -> service.listRecords(
+ readString(parameters.get(PARAM_TABLE)),
+ readString(parameters.get(PARAM_VIEW)),
+ readString(parameters.get(PARAM_FILTER_BY_FORMULA)),
+ readInteger(parameters.get(PARAM_MAX_RECORDS)),
+ readStringList(parameters.get(PARAM_FIELD_NAMES)),
+ readString(parameters.get(PARAM_SORT_FIELD)),
+ readString(parameters.get(PARAM_SORT_DIRECTION)));
+ case "get_record" -> service.getRecord(
+ readString(parameters.get(PARAM_TABLE)),
+ readString(parameters.get(PARAM_RECORD_ID)));
+ case "create_record" -> service.createRecord(
+ readString(parameters.get(PARAM_TABLE)),
+ readFields(parameters.get(PARAM_FIELDS)),
+ readOptionalBoolean(parameters, PARAM_TYPECAST));
+ case "update_record" -> service.updateRecord(
+ readString(parameters.get(PARAM_TABLE)),
+ readString(parameters.get(PARAM_RECORD_ID)),
+ readFields(parameters.get(PARAM_FIELDS)),
+ readOptionalBoolean(parameters, PARAM_TYPECAST));
+ case "delete_record" -> service.deleteRecord(
+ readString(parameters.get(PARAM_TABLE)),
+ readString(parameters.get(PARAM_RECORD_ID)));
+ default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED,
+ "Unsupported airtable_records operation: " + operation);
+ };
+ } catch (IllegalArgumentException ex) {
+ return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, ex.getMessage());
+ }
+ }
+
+ private Map requiredWhen(String operation, List requiredFields) {
+ return Map.of(
+ "if", Map.of(
+ PROPERTIES, Map.of(
+ PARAM_OPERATION, Map.of("const", operation))),
+ "then", Map.of(
+ REQUIRED, requiredFields));
+ }
+
+ private String readString(Object value) {
+ return value instanceof String text ? text.trim() : null;
+ }
+
+ private Integer readInteger(Object value) {
+ if (value instanceof Number number) {
+ return number.intValue();
+ }
+ if (value instanceof String text && !text.isBlank()) {
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException ex) {
+ throw new IllegalArgumentException("max_records must be an integer");
+ }
+ }
+ return null;
+ }
+
+ private Optional readOptionalBoolean(Map parameters, String key) {
+ if (!parameters.containsKey(key)) {
+ return Optional.empty();
+ }
+ Object value = parameters.get(key);
+ if (value instanceof Boolean bool) {
+ return Optional.of(bool);
+ }
+ throw new IllegalArgumentException(key + " must be a boolean");
+ }
+
+ private List readStringList(Object value) {
+ Set normalized = new LinkedHashSet<>();
+ if (value instanceof List> list) {
+ for (Object item : list) {
+ String text = readString(item);
+ if (hasText(text)) {
+ normalized.add(text);
+ }
+ }
+ } else if (value instanceof String text) {
+ String[] parts = text.split("[,\\n]");
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ normalized.add(trimmed);
+ }
+ }
+ }
+ return new ArrayList<>(normalized);
+ }
+
+ private Map readFields(Object value) {
+ if (value == null) {
+ throw new IllegalArgumentException("fields must be a JSON object or map");
+ }
+ if (value instanceof Map, ?> map) {
+ Map normalized = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (entry.getKey() == null) {
+ continue;
+ }
+ normalized.put(entry.getKey().toString(), entry.getValue());
+ }
+ return normalized;
+ }
+ if (value instanceof String text && !text.isBlank()) {
+ try {
+ return objectMapper.readValue(text, new TypeReference<>() {
+ });
+ } catch (Exception ex) {
+ throw new IllegalArgumentException("fields must be a JSON object or map");
+ }
+ }
+ throw new IllegalArgumentException("fields must be a JSON object or map");
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java
new file mode 100644
index 0000000..75fed71
--- /dev/null
+++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java
@@ -0,0 +1,244 @@
+package me.golemcore.plugins.golemcore.airtable.support;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import me.golemcore.plugins.golemcore.airtable.AirtablePluginConfig;
+import me.golemcore.plugins.golemcore.airtable.AirtablePluginConfigService;
+import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class AirtableApiClient {
+
+ private static final MediaType APPLICATION_JSON = MediaType.get("application/json");
+ private static final TypeReference