Skip to content

Commit ab2ab04

Browse files
renezander030claude
andcommitted
adapters: add Airtable + Google Workspace storage adapters
Airtable (read+write): table = project, record = task; primary field is the title, other fields the body. Scoped-PAT auth. searchByQuery + bulkFetch. Google (read-only corpus): Sheets/Docs/Slides as projects/tasks, text extracted (sheets render as markdown tables). OAuth as a dedicated, share-scoped Workspace user so a leaked token can't reach the rest of Drive. bulkFetch. Both conformance-clean (shape/capabilities/url-for) with offline mocked-API unit tests (10/10 each). README architecture tree, adapter table, and per-adapter blurbs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aeb1221 commit ab2ab04

12 files changed

Lines changed: 1433 additions & 0 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ agentic-task-system/
197197
│ ├── adapter-okf/ # Open Knowledge Format markdown bundles
198198
│ ├── adapter-taskmaster/ # local tagged tasks.json + native dependencies
199199
│ ├── adapter-beads/ # official bd JSON CLI + native dependency graph
200+
│ ├── adapter-airtable/ # Airtable bases over the REST API (table = project, record = task)
201+
│ ├── adapter-google/ # Google Sheets/Docs/Slides as a read-only corpus
200202
│ ├── adapter-notion/ # planned
201203
│ ├── cli/ # `ats` command
202204
│ └── mcp/ # `@reneza/ats-mcp` — MCP server
@@ -248,6 +250,8 @@ Full spec: [`docs/adapter-interface.md`](docs/adapter-interface.md).
248250
| `okf` | shipped v0.6 | Open Knowledge Format markdown bundle |
249251
| `taskmaster` | shipped v0.6 | local `.taskmaster/tasks/tasks.json` |
250252
| `beads` | shipped v0.6 | repository-local Beads through `bd --json` |
253+
| `airtable` | shipped v0.8 | Airtable REST API (table = project, record = task) |
254+
| `google` | shipped v0.8 | Google Sheets / Docs / Slides (read-only corpus) |
251255
| `notion` | planned | Notion API |
252256
| `things` | wishlist | Things URL scheme + AppleScript |
253257
| `apple-notes` | wishlist | AppleScript |
@@ -296,6 +300,36 @@ bundles as ATS projects and concept documents. Point it at a bundle with
296300
`ATS_OKF_BUNDLE` to query vendor-neutral markdown/frontmatter knowledge catalogs
297301
through the same retrieval, graph, and MCP surface.
298302

303+
### Airtable: any base as agent-queryable records
304+
305+
The [Airtable adapter](packages/adapter-airtable/README.md) maps a table to a
306+
project and a record to a task — the primary field becomes the title, the rest of
307+
the fields become the markdown body — so any base is searchable through `ats find`
308+
and MCP, fused by RRF with your other sources. Auth is a Personal Access Token
309+
scoped to only the bases you grant, keeping the blast radius small:
310+
311+
```bash
312+
npm install -g @reneza/ats-cli @reneza/ats-adapter-airtable
313+
export ATS_AIRTABLE_TOKEN=pat... ATS_AIRTABLE_BASES=appXXX
314+
ats config use @reneza/ats-adapter-airtable
315+
ats find "supplier reconciliation"
316+
```
317+
318+
### Google: Sheets, Docs, and Slides as a read-only corpus
319+
320+
The [Google adapter](packages/adapter-google/README.md) pulls Google Sheets,
321+
Docs, and Slides into ATS retrieval as a read-only corpus — a doc type is a
322+
project, a file is a task, and the body is the extracted text (Sheets render as
323+
markdown tables). It authenticates as a **dedicated, read-only Workspace user**
324+
who only sees the files you share with them, so a leaked token can never reach the
325+
rest of anyone's Drive:
326+
327+
```bash
328+
npm install -g @reneza/ats-cli @reneza/ats-adapter-google
329+
ats config use @reneza/ats-adapter-google # then authLogin → authExchange as the dedicated user
330+
ats find "Q3 pricing model"
331+
```
332+
299333
The scaffold + conformance kit + interface doc make it a couple-hundred-line job for most well-behaved APIs.
300334

301335
## CLI surface
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# @reneza/ats-adapter-airtable
2+
3+
An [Agentic Task System](https://github.com/renezander030/agentic-task-system) storage adapter over **Airtable**. Point ATS at a base and every record becomes agent-queryable alongside your other ATS sources, fused by RRF and exposed over MCP. Adapter, not migration — the data stays in Airtable.
4+
5+
## Mapping
6+
7+
| ATS | Airtable |
8+
| ---------- | --------------------------------------------------- |
9+
| Project | a **table** (id = `baseId/tableId`) |
10+
| Task | a **record** (id = `recXXXXXXXX`) |
11+
| `title` | the table's **primary field** |
12+
| `content` | the remaining fields, serialized as `**Field:** value` markdown |
13+
| `tags` | a field named `Tags` (multi-select or comma string), else `[]` |
14+
| `dueDate` | a date field named `Due`/`Deadline`, if present |
15+
| `modifiedTime` | a `lastModifiedTime` field if present, else the record's `createdTime` |
16+
17+
`searchByQuery` and `bulkFetch` are implemented (client-side filter over a corpus pull); `embeddings` is intentionally omitted, so Core handles dense retrieval.
18+
19+
## Auth — scope the token, cap the blast radius
20+
21+
Auth is an Airtable **Personal Access Token (PAT)**. Create one at
22+
<https://airtable.com/create/tokens> with scopes `data.records:read` +
23+
`schema.bases:read` (add `data.records:write` only if you want ATS to create or
24+
update records). **Grant the token access to only the specific base(s) you want
25+
ATS to see.** A token scoped to one base limits the blast radius if it ever
26+
leaks — the same principle as sharing Google docs with a dedicated, read-only
27+
workspace user rather than handing over a whole account.
28+
29+
Configure via env:
30+
31+
```sh
32+
export ATS_AIRTABLE_TOKEN=pat...
33+
export ATS_AIRTABLE_BASES=appXXX,appYYY # optional allow-list; omit to use every base the token can see
34+
```
35+
36+
or `~/.config/ats/airtable.json` (chmod 600):
37+
38+
```json
39+
{
40+
"token": "pat...",
41+
"bases": ["appXXX"],
42+
"defaultProject": "appXXX/tblYYY"
43+
}
44+
```
45+
46+
## Use
47+
48+
```sh
49+
ats config use @reneza/ats-adapter-airtable # or: ATS_ADAPTER=./packages/adapter-airtable
50+
ats doctor # checks token, schema access, capabilities
51+
ats find "supplier reconciliation" # retrieval across your Airtable records
52+
ats adapter test ./packages/adapter-airtable # conformance kit (add --write to exercise create/update)
53+
```
54+
55+
## Verify
56+
57+
```sh
58+
node --test # offline unit tests (mocked Airtable API)
59+
```

packages/adapter-airtable/api.js

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* Airtable REST + mapping helpers for @reneza/ats-adapter-airtable.
3+
*
4+
* Mapping decision (see README): an Airtable **table** is an ATS Project and an
5+
* Airtable **record** is an ATS Task. projectId is the compound `baseId/tableId`;
6+
* taskId is the record id (`recXXXXXXXX`). The record's primary field becomes the
7+
* task title; the remaining fields are serialized into the markdown body so Core's
8+
* retrieval has text to match on.
9+
*
10+
* Zero runtime deps: uses global fetch (Node >= 18).
11+
*/
12+
13+
import fs from 'node:fs';
14+
import os from 'node:os';
15+
import path from 'node:path';
16+
17+
const DEFAULT_ENDPOINT = 'https://api.airtable.com';
18+
const PAGE_SIZE = 100;
19+
20+
/** Per-process schema cache: baseId -> { tables, fetchedAt }. Tables rarely change within a run. */
21+
const schemaCache = new Map();
22+
23+
/** Resolve config from env first, then ~/.config/ats/airtable.json. Env wins per key. */
24+
export function loadConfig() {
25+
let file = {};
26+
const p = configPath();
27+
try {
28+
if (fs.existsSync(p)) file = JSON.parse(fs.readFileSync(p, 'utf8')) || {};
29+
} catch {
30+
file = {};
31+
}
32+
const basesEnv = process.env.ATS_AIRTABLE_BASES;
33+
const bases =
34+
(basesEnv ? basesEnv.split(',') : Array.isArray(file.bases) ? file.bases : [])
35+
.map((s) => String(s).trim())
36+
.filter(Boolean);
37+
return {
38+
token: process.env.ATS_AIRTABLE_TOKEN || file.token || '',
39+
endpoint: process.env.ATS_AIRTABLE_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT,
40+
bases, // optional allow-list of appXXX ids; empty = discover via Meta API
41+
defaultProject:
42+
process.env.ATS_AIRTABLE_DEFAULT_PROJECT || file.defaultProject || '',
43+
};
44+
}
45+
46+
export function configPath() {
47+
return (
48+
process.env.ATS_AIRTABLE_CONFIG ||
49+
path.join(os.homedir(), '.config', 'ats', 'airtable.json')
50+
);
51+
}
52+
53+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
54+
55+
/**
56+
* Authenticated Airtable request with JSON parsing and 429 backoff.
57+
* @param {string} apiPath e.g. `/v0/meta/bases`
58+
* @param {{ method?: string, query?: Record<string,string|number|undefined>, body?: any, cfg?: object }} [opts]
59+
*/
60+
export async function air(apiPath, opts = {}) {
61+
const cfg = opts.cfg || loadConfig();
62+
if (!cfg.token) throw new Error('Airtable: no token. Set ATS_AIRTABLE_TOKEN or write ~/.config/ats/airtable.json');
63+
const url = new URL(cfg.endpoint.replace(/\/$/, '') + apiPath);
64+
for (const [k, v] of Object.entries(opts.query || {})) {
65+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
66+
}
67+
const init = {
68+
method: opts.method || 'GET',
69+
headers: { Authorization: `Bearer ${cfg.token}` },
70+
};
71+
if (opts.body !== undefined) {
72+
init.headers['Content-Type'] = 'application/json';
73+
init.body = JSON.stringify(opts.body);
74+
}
75+
76+
// Airtable rate-limits at 5 req/sec/base -> 429 with Retry-After. Retry a few times.
77+
for (let attempt = 0; ; attempt++) {
78+
const res = await fetch(url, init);
79+
if (res.status === 429 && attempt < 4) {
80+
const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * (attempt + 1);
81+
await sleep(wait);
82+
continue;
83+
}
84+
const text = await res.text();
85+
let json;
86+
try {
87+
json = text ? JSON.parse(text) : {};
88+
} catch {
89+
json = { raw: text };
90+
}
91+
if (!res.ok) {
92+
const msg = json?.error?.message || json?.error?.type || json?.error || text || res.statusText;
93+
throw new Error(`Airtable ${res.status} on ${apiPath}: ${typeof msg === 'string' ? msg : JSON.stringify(msg)}`);
94+
}
95+
return json;
96+
}
97+
}
98+
99+
/** List bases the token can see (Meta API), honoring the optional allow-list. */
100+
export async function listBases(cfg = loadConfig()) {
101+
if (cfg.bases.length) return cfg.bases.map((id) => ({ id, name: id }));
102+
const out = [];
103+
let offset;
104+
do {
105+
const page = await air('/v0/meta/bases', { query: { offset }, cfg });
106+
for (const b of page.bases || []) out.push({ id: b.id, name: b.name || b.id });
107+
offset = page.offset;
108+
} while (offset);
109+
return out;
110+
}
111+
112+
/** Table schema for a base, cached per process. */
113+
export async function listTables(baseId, cfg = loadConfig()) {
114+
const cached = schemaCache.get(baseId);
115+
if (cached) return cached;
116+
const page = await air(`/v0/meta/bases/${baseId}/tables`, { cfg });
117+
const tables = page.tables || [];
118+
schemaCache.set(baseId, tables);
119+
return tables;
120+
}
121+
122+
export function clearSchemaCache() {
123+
schemaCache.clear();
124+
}
125+
126+
/** Split a compound `baseId/tableId` projectId. */
127+
export function splitProjectId(projectId) {
128+
const i = String(projectId).indexOf('/');
129+
if (i < 0) throw new Error(`Airtable projectId must be "baseId/tableId", got "${projectId}"`);
130+
return { baseId: projectId.slice(0, i), tableId: projectId.slice(i + 1) };
131+
}
132+
133+
export function makeProjectId(baseId, tableId) {
134+
return `${baseId}/${tableId}`;
135+
}
136+
137+
/** Render any Airtable cell value to a short string for title/body/search. */
138+
export function stringifyCell(v) {
139+
if (v === undefined || v === null) return '';
140+
if (typeof v === 'string') return v;
141+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
142+
if (Array.isArray(v)) return v.map(stringifyCell).filter(Boolean).join(', ');
143+
if (typeof v === 'object') {
144+
// collaborators, attachments, button, barcode, etc.
145+
return v.name || v.email || v.text || v.url || v.filename || v.label || JSON.stringify(v);
146+
}
147+
return String(v);
148+
}
149+
150+
const isDateType = (t) => t === 'date' || t === 'dateTime';
151+
152+
/** Map a raw Airtable record + its table schema into an ATS Task. */
153+
export function recordToTask(baseId, table, rec) {
154+
const fields = table.fields || [];
155+
const primary = fields.find((f) => f.id === table.primaryFieldId) || fields[0] || { name: 'Name' };
156+
const title = stringifyCell(rec.fields?.[primary.name]) || '(untitled)';
157+
158+
const body = fields
159+
.filter((f) => f.id !== (primary.id ?? primary.name))
160+
.map((f) => {
161+
const val = stringifyCell(rec.fields?.[f.name]);
162+
return val ? `**${f.name}:** ${val}` : '';
163+
})
164+
.filter(Boolean)
165+
.join('\n');
166+
167+
const tagsField = fields.find((f) => /^tags?$/i.test(f.name));
168+
const tags = tagsField ? toArray(rec.fields?.[tagsField.name]) : [];
169+
170+
const dueField = fields.find((f) => isDateType(f.type) && /(due|deadline|fällig)/i.test(f.name));
171+
const dueRaw = dueField ? rec.fields?.[dueField.name] : undefined;
172+
173+
const lastModField = fields.find((f) => f.type === 'lastModifiedTime');
174+
const modifiedTime =
175+
(lastModField && rec.fields?.[lastModField.name]) || rec.createdTime || new Date(0).toISOString();
176+
177+
const task = {
178+
id: rec.id,
179+
title,
180+
content: body,
181+
projectId: makeProjectId(baseId, table.id),
182+
tags,
183+
modifiedTime,
184+
raw: rec,
185+
};
186+
if (dueRaw) task.dueDate = new Date(dueRaw).toISOString();
187+
return task;
188+
}
189+
190+
function toArray(v) {
191+
if (v === undefined || v === null) return [];
192+
if (Array.isArray(v)) return v.map(stringifyCell).filter(Boolean);
193+
return String(v)
194+
.split(',')
195+
.map((s) => s.trim())
196+
.filter(Boolean);
197+
}
198+
199+
/** Page through every record of one table. */
200+
export async function listRecords(baseId, tableId, cfg = loadConfig()) {
201+
const records = [];
202+
let offset;
203+
do {
204+
const page = await air(`/v0/${baseId}/${encodeURIComponent(tableId)}`, {
205+
query: { pageSize: PAGE_SIZE, offset },
206+
cfg,
207+
});
208+
for (const r of page.records || []) records.push(r);
209+
offset = page.offset;
210+
} while (offset);
211+
return records;
212+
}
213+
214+
export async function getRecord(baseId, tableId, recordId, cfg = loadConfig()) {
215+
return air(`/v0/${baseId}/${encodeURIComponent(tableId)}/${recordId}`, { cfg });
216+
}
217+
218+
export async function createRecord(baseId, tableId, fields, cfg = loadConfig()) {
219+
return air(`/v0/${baseId}/${encodeURIComponent(tableId)}`, {
220+
method: 'POST',
221+
body: { fields, typecast: true },
222+
cfg,
223+
});
224+
}
225+
226+
export async function updateRecord(baseId, tableId, recordId, fields, cfg = loadConfig()) {
227+
return air(`/v0/${baseId}/${encodeURIComponent(tableId)}/${recordId}`, {
228+
method: 'PATCH',
229+
body: { fields, typecast: true },
230+
cfg,
231+
});
232+
}
233+
234+
/**
235+
* Build an Airtable fields payload from an ATS TaskInput/patch, given a table schema.
236+
* - title -> primary field
237+
* - content -> the first long-text (multilineText) field, or a field named Notes/Content
238+
* - tags -> a field named Tags (if present)
239+
*/
240+
export function taskInputToFields(table, input) {
241+
const fields = table.fields || [];
242+
const primary = fields.find((f) => f.id === table.primaryFieldId) || fields[0];
243+
const out = {};
244+
if (input.title !== undefined && primary) out[primary.name] = input.title;
245+
246+
if (input.content !== undefined) {
247+
const contentField =
248+
fields.find((f) => f.type === 'multilineText' && f.id !== primary?.id) ||
249+
fields.find((f) => /^(notes?|content|body|beschreibung)$/i.test(f.name) && f.id !== primary?.id);
250+
if (contentField) out[contentField.name] = input.content;
251+
}
252+
253+
if (input.tags !== undefined) {
254+
const tagsField = fields.find((f) => /^tags?$/i.test(f.name));
255+
if (tagsField) {
256+
out[tagsField.name] =
257+
tagsField.type === 'multipleSelects' ? input.tags : (input.tags || []).join(', ');
258+
}
259+
}
260+
return out;
261+
}
262+
263+
export function urlForRecord(baseId, tableId, recordId) {
264+
return 'https://airtable.com/' + [baseId, tableId, recordId].filter(Boolean).join('/');
265+
}

0 commit comments

Comments
 (0)