Open-source CLI and MCP server for Google Sheets enrichment — waterfalls, conditions, HTTP API integrations.
$npm install -g github:eliasstravik/rowbound A CLI for GTM Engineering in Google Sheets with Claude Code.
Install
Prerequisites
- Node.js 22+ —
node --versionmust be >= 22.0.0 - gws CLI — Google Workspace CLI for Sheets access
npm install -g @googleworkspace/cli
gws auth setup # first time: creates Cloud project, enables APIs, logs in
gws auth login # subsequent logins
Quick install
npm install -g github:eliasstravik/rowbound
Build from source
git clone https://github.com/eliasstravik/rowbound.git
cd rowbound
npm install
npm run dev -- <command>
Quick Start
# 1. Initialize a sheet
rowbound init <spreadsheet-id>
# 2. Add an action
rowbound config add-action <spreadsheet-id> --json '{
"id": "enrich_company",
"type": "http",
"target": "company_info",
"method": "GET",
"url": "https://api.example.com/company?domain={{row.domain}}",
"headers": { "Authorization": "Bearer {{env.API_KEY}}" },
"extract": "$.name"
}'
# 3. Store API keys and run
rowbound env set API_KEY=your_key_here
rowbound run <spreadsheet-id>
rowbound run <spreadsheet-id> --dry-run # preview first
Column names are automatically resolved to stable IDs when you run rowbound sync.
Features
Sources — Create rows from external data
- HTTP sources — fetch from any API, extract array from response, map columns via JSONPath
- Exec sources — run shell commands, parse JSON output into rows
- Script sources — run named scripts to generate rows from their output
- Webhook sources — accept inbound POST payloads, create rows in real-time
- Deduplication — skip or update existing rows based on a match column
- Scheduling — run sources manually, hourly, daily, or weekly
Actions — Enrich existing rows
- HTTP actions — call any REST API with templated URLs, headers, and bodies; extract values with JSONPath;
ifEmptyfallback when extract returns nothing - Waterfall actions — try multiple providers in order until one returns a result (e.g., Clearbit → Apollo → Hunter)
- Formula actions — compute derived values with JavaScript expressions using
{{Column Name}}references - AI actions — run headless Claude or Codex per row with configurable model, max turns, tools, and
baremode for faster startup - Exec actions — run shell commands and capture stdout
- Script actions — run reusable named scripts stored in config; supports bash, python3, and node runtimes
- Lookup actions — pull data from other tabs (boolean, count, or full row JSON)
- Write actions — push data to other tabs with column mapping; supports append, upsert, and array expansion via
expandPath - Per-action environment variables — inject env vars per action (e.g.,
PLAYWRIGHT_HEADLESS=true)
Pipeline
- Conditional execution — skip actions per-row with
whenexpressions - Smart skip — automatically skips rows where the target cell already has a value
- Watch mode — poll sheets on an interval or trigger runs via webhook
- Column tracking — stable column IDs that survive header renames and reordering
--columnsflag — target specific columns by letter (e.g.,--columns A-C,E,AP)--rowsflag — flexible row specs (e.g.,--rows 2-5,8,10-12)- Rate limiting — configurable seconds between requests (default: 1 per second)
- Timeouts in seconds — all user-facing timeouts in seconds, not milliseconds
- Retry with backoff — exponential, linear, or fixed backoff on failures
- Structured error handling — per-action
onErrorconfig maps status codes to actions (skip, write fallback) - MCP server — expose all operations as Model Context Protocol tools for Claude Desktop and other AI assistants
- Run history — track pipeline executions with per-action summaries, durations, and error logs
- Dry run — preview what would change without writing to the sheet
- Per-tab stop/start — enable or disable processing per tab; stops mid-run if toggled during execution
- Per-tab settings — override concurrency, rate limit, retries, and backoff per tab
- BYOK — bring your own API keys, pay only for the APIs you use
CLI Commands
| Command | Description |
|---|---|
rowbound init <sheetId> |
Initialize a sheet with a default pipeline config |
rowbound run <sheetId> |
Run the enrichment pipeline (--dry-run, --rows, --columns) |
rowbound status <sheetId> |
Show pipeline status and enrichment rates |
rowbound watch <sheetId> |
Watch for changes and run continuously (--interval, --port) |
rowbound sync <sheetId> |
Reconcile columns, validate config, fix issues |
rowbound config show <sheetId> |
Display the pipeline config as JSON |
rowbound config add-action <sheetId> |
Add an action to the pipeline |
rowbound config remove-action <sheetId> |
Remove an action by ID |
rowbound config update-action <sheetId> |
Update an action (merge partial JSON) |
rowbound config list-actions <sheetId> |
List configured actions (--json) |
rowbound config add-source <sheetId> |
Add a source to the pipeline |
rowbound config remove-source <sheetId> |
Remove a source by ID |
rowbound config update-source <sheetId> |
Update a source (merge partial JSON) |
rowbound config set <sheetId> |
Update pipeline settings (--enabled, --disabled, --concurrency, --rate-limit, etc.) |
rowbound config add-script <sheetId> |
Add a script to the pipeline config |
rowbound config remove-script <sheetId> |
Remove a script by name |
rowbound config update-script <sheetId> |
Update a script (merge partial JSON) |
rowbound config validate <sheetId> |
Validate the pipeline config |
rowbound runs [runId] |
List recent runs or view a specific run |
rowbound runs clear |
Delete all run history |
rowbound source run <sheetId> |
Run a source to create rows (--source, --dry-run) |
rowbound source list <sheetId> |
List configured sources |
rowbound env set <KEY=value> |
Store an API key globally |
rowbound env remove <KEY> |
Remove a stored key |
rowbound env list |
List stored keys (values masked) |
rowbound mcp |
Start the MCP server (stdio) |
MCP Server
Rowbound exposes all pipeline operations as MCP tools. Add this to your Claude Desktop config:
{
"mcpServers": {
"rowbound": {
"command": "rowbound",
"args": ["mcp"]
}
}
}
| Tool | Description |
|---|---|
init_pipeline |
Initialize a sheet with a default pipeline config |
run_pipeline |
Run the enrichment pipeline |
add_action / remove_action / update_action |
Manage pipeline actions |
add_source / remove_source / update_source |
Manage data sources |
run_source |
Execute a source to create rows |
update_settings |
Update pipeline settings (concurrency, rate limit, retry) |
sync_columns |
Sync the column registry with the current sheet state |
get_config / validate_config |
Read or validate the pipeline config |
get_status |
Return pipeline status with enrichment rates |
dry_run |
Run in dry mode (no writes) |
start_watch / stop_watch |
Manage watch mode |
preview_rows |
Read and display rows from the sheet |
list_runs / get_run |
View pipeline run history |
Source Types
Sources create rows from external data. They run before actions in the pipeline — new rows are created first, then actions enrich them on the next run.
http source
Fetch from an API and create rows from the response.
{
"id": "search_companies",
"type": "http",
"method": "POST",
"url": "https://api.blitz-api.ai/v2/search/company",
"headers": { "x-api-key": "{{env.BLITZ_API_KEY}}" },
"body": { "industry": "restaurants", "country_code": ["SE"] },
"extract": "$",
"extractPath": "$.results",
"columns": { "Title": "$.company_name", "Website": "$.website_url", "LinkedIn": "$.linkedin_url" },
"dedup": "Website",
"schedule": "daily"
}
exec source
Run a shell command and parse JSON output into rows.
{
"id": "import_leads",
"type": "exec",
"command": "curl -s https://api.example.com/leads",
"extract": "$.data",
"columns": { "Name": "$.name", "Email": "$.email" },
"dedup": "Email",
"updateExisting": true
}
webhook source
Accept inbound POST payloads and create rows. Used with rowbound watch.
{
"id": "form_submissions",
"type": "webhook",
"columns": { "Name": "$.name", "Email": "$.email", "Company": "$.company" },
"dedup": "Email"
}
Source options
| Field | Description |
|---|---|
columns |
Maps sheet column headers to JSONPath per item: { "Name": "$.name" }. Use $.nested.field for nested data, or literal strings for static values. |
extract / extractPath |
JSONPath to locate the array in the response. extractPath drills into a nested object first (e.g., $.results extracts from {"results": [...]}). |
dedup |
Column header to deduplicate on. Existing rows with the same value are skipped. |
updateExisting |
When true and dedup is set, update matched rows instead of skipping (default: false). |
schedule |
"manual" (default), "hourly", "daily", or "weekly". Watch mode checks schedules automatically. |
script source
Run a named script (defined in the scripts config section) and parse its output into rows.
{
"id": "import_from_script",
"type": "script",
"script": "fetch_leads",
"args": ["--format", "json"],
"extract": "$.leads",
"columns": { "Name": "$.name", "Email": "$.email" },
"dedup": "Email"
}
Scripts
Scripts are reusable code blocks stored in your pipeline config. Define a script once, then reference it from multiple actions or sources by name. Each script has a runtime (the interpreter) and code (the script body).
Config section
Scripts are stored under the scripts key in your config (global or per-tab):
{
"scripts": {
"claude_json": {
"runtime": "bash",
"code": "#!/bin/bash\ncurl -s https://api.anthropic.com/v1/messages \\\n -H \"x-api-key: $ANTHROPIC_API_KEY\" \\\n -H \"content-type: application/json\" \\\n -d \"$1\""
},
"parse_csv": {
"runtime": "python3",
"code": "import csv, json, sys\nwith open(sys.argv[1]) as f:\n print(json.dumps(list(csv.DictReader(f))))"
}
}
}
Supported runtimes: bash, python3, node.
Referencing scripts from actions
Use "type": "script" in an action to run a named script per row. The script receives row data via template-expanded arguments and its stdout is captured as the result.
{
"id": "enrich_with_claude",
"type": "script",
"target": "ai_summary",
"script": "claude_json",
"args": ["{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":256,\"messages\":[{\"role\":\"user\",\"content\":\"Summarize: {{row.company}}\"}]}"],
"extract": "$.content[0].text",
"timeout": 60000
}
Referencing scripts from sources
Use "type": "script" in a source to run a named script and create rows from its output.
{
"id": "load_leads",
"type": "script",
"script": "parse_csv",
"args": ["/tmp/leads.csv"],
"columns": { "Name": "$.name", "Email": "$.email" },
"dedup": "Email"
}
CLI commands
| Command | Description |
|---|---|
rowbound config add-script <sheetId> |
Add a script to the config |
rowbound config remove-script <sheetId> |
Remove a script by name |
rowbound config update-script <sheetId> |
Update a script (merge partial JSON) |
Action Types
Templates use {{row.column}} for row data and {{env.KEY}} for environment variables. Actions support conditional execution with when expressions and structured error handling with onError.
http
Call a REST API and extract a value with JSONPath.
{
"id": "get_company",
"type": "http",
"target": "company_name",
"when": "row.domain !== ''",
"method": "GET",
"url": "https://api.clearbit.com/v2/companies/find?domain={{row.domain}}",
"headers": { "Authorization": "Bearer {{env.CLEARBIT_API_KEY}}" },
"extract": "$.name",
"ifEmpty": "❌",
"onError": { "404": "skip", "429": "skip", "default": { "write": "ERROR" } }
}
waterfall
Try multiple providers in order. First non-empty result wins.
{
"id": "find_email",
"type": "waterfall",
"target": "email",
"providers": [
{
"name": "hunter",
"method": "GET",
"url": "https://api.hunter.io/v2/email-finder?domain={{row.domain}}&first_name={{row.first_name}}&last_name={{row.last_name}}&api_key={{env.HUNTER_API_KEY}}",
"extract": "$.data.email"
},
{
"name": "apollo",
"method": "POST",
"url": "https://api.apollo.io/api/v1/people/match",
"headers": { "Content-Type": "application/json", "X-Api-Key": "{{env.APOLLO_API_KEY}}" },
"body": { "email": "{{row.personal_email}}", "domain": "{{row.domain}}" },
"extract": "$.person.email"
}
]
}
formula
Compute a value with a sandboxed JavaScript expression.
{
"id": "full_name",
"type": "formula",
"target": "full_name",
"expression": "`${row.first_name} ${row.last_name}`"
}
exec
Run a shell command and capture stdout. Template values are shell-escaped.
{
"id": "whois_lookup",
"type": "exec",
"target": "registrar",
"command": "whois {{row.domain}} | grep 'Registrar:' | head -1 | cut -d: -f2",
"timeout": 10000,
"onError": { "default": "skip" }
}
lookup
Pull data from another tab by matching a column value. Source tab data is cached per pipeline run for performance.
{
"id": "get_company_info",
"type": "lookup",
"target": "company_name",
"sourceTab": "Companies",
"matchColumn": "Domain",
"matchValue": "{{row.domain}}",
"matchOperator": "equals",
"returnColumn": "Name",
"matchMode": "first"
}
Use "matchMode": "all" to return all matches as a JSON array. Use "matchOperator": "contains" for substring matching.
write
Push data to another tab with column mapping. Supports append, upsert, and array expansion.
{
"id": "export_contacts",
"type": "write",
"target": "export_status",
"destTab": "Contacts",
"columns": {
"Company": "{{row.company}}",
"Name": "{{item.name}}",
"Title": "{{item.title}}",
"Email": "{{item.email}}"
},
"expand": "{{row.contacts_json}}",
"expandPath": "$.contacts"
}
- append (default) — always create new rows
- upsert — update existing rows if
upsertMatchcolumn matches, otherwise append - expand + expandPath — expand a JSON array into multiple rows; use
{{item.field}}in column templates to access element data
script
Run a named script and capture its output. Scripts are defined in the scripts config section and referenced by name.
{
"id": "ai_summary",
"type": "script",
"target": "summary",
"script": "claude_json",
"args": ["{\"prompt\":\"Summarize {{row.company}}\"}"],
"extract": "$.content[0].text",
"timeout": 60000
}
Error handling
Actions can define onError to map HTTP status codes (or exit codes for exec) to behaviors:
| Action | Effect |
|---|---|
"skip" |
Skip this action for the current row |
"stop_provider" |
Stop the current waterfall provider, try the next |
{"write": "value"} |
Write a fallback value to the target cell |
Google Sheets Sidebar
Rowbound includes an Apps Script sidebar that lets you configure actions directly in Google Sheets — no CLI needed. Click a column, edit the action config in a sidebar UI, and save. The sidebar reads and writes the same Developer Metadata config as the CLI, so both stay in sync.
Setup
- Open your Google Sheet → Extensions → Apps Script
- Replace the contents of
Code.gswithapps-script/Code.gs - Click + next to Files → HTML → name it
Sidebar→ pasteapps-script/Sidebar.html - In the left panel, click + next to Services → select Google Sheets API → set Identifier to
Sheets→ click Add - Press Cmd+S (or Ctrl+S) to save
- Reload your spreadsheet — a Rowbound menu appears in the menu bar
Usage
- Rowbound → Actions — view all configured actions, reorder them, or create new ones
- Rowbound → Sources — view and manage data sources (http, exec, webhook, script) with type-specific editors
- Rowbound → Scripts — view, create, edit, and delete reusable scripts with runtime and code editor
- Rowbound → Settings — edit pipeline settings (concurrency, rate limit, retries, backoff)
- Click any action or source to edit its full config
- The column dropdown (●/○ indicators) lets you navigate between columns and see which ones have actions
Supported types
All action types are configurable through the sidebar: HTTP, Waterfall, Formula, Exec, Lookup, Write, and Script. All source types are also supported: HTTP, Exec, Webhook, and Script — including column mapping, dedup, schedule, and update-existing settings.
Note: The sidebar is a config editor only — it doesn't execute the pipeline. Use
rowbound runvia the CLI to execute. Theexecaction type can be configured in the sidebar but only executes via the CLI (no shell access in Apps Script).
Multi-sheet use
To use the sidebar across multiple sheets, repeat the setup steps for each sheet. Alternatively, you can set up a test deployment:
- In the Apps Script editor → Deploy → Test deployments
- Click the gear icon → select Editor Add-on
- Click Create new test → select any sheet as test document → Save test
- The add-on will be available in your test document; for other sheets, repeat the paste-in setup
Development
npm install
npm run dev -- <command>
| Command | Description |
|---|---|
npm run dev -- <command> |
Run a CLI command in development mode |
npm run build |
Type-check and build for production |
npm test |
Run tests |
npm run lint |
Lint with Biome |
Security
- Expression sandbox —
whenconditions and formula expressions run in Node.jsvm.runInContextwith keyword blocking; convenience sandbox, not a security boundary - Exec actions — shell commands run locally; template values are shell-escaped but use only with trusted data
- SSRF protection — HTTP requests enforce HTTPS by default and block private/internal IP ranges; set
ROWBOUND_ALLOW_HTTP=truefor local dev - Webhook auth — set
ROWBOUND_WEBHOOK_TOKENto require bearer token authentication; server binds to localhost by default - API keys — stored in
~/.config/rowbound/.envwith600permissions;.gitignoreexcludes.env - Env filtering — only
ROWBOUND_*,NODE_ENV,PATH, and explicitly referenced vars are exposed to actions - MCP permissions — the MCP server inherits the authenticated
gwsCLI session permissions
License
{
"request": {
"method": "GET",
"url": "https://api.eliasstravik.com/projects/rowbound"
},
"response": {
"status": "200 OK",
"headers": {
"content-type": "application/json; charset=utf-8",
"x-powered-by": "one person + systems"
},
"body": {
"name": "rowbound",
"description": "Open-source CLI and MCP server for Google Sheets enrichment — waterfalls, conditions, HTTP API integrations",
"language": "TypeScript",
"stars": 40,
"forks": 10,
"repo": "https://github.com/eliasstravik/rowbound",
"readme": "https://github.com/eliasstravik/rowbound#readme",
"install": "npm install -g github:eliasstravik/rowbound"
}
}
}