# Tables

A table is a list your workspace keeps between sessions: the leads a campaign enriches, the creators you are about to message, the companies you monitor. Every agent on the workspace reads and writes the same rows (Claude Code, Codex, a claude.ai or ChatGPT connector, a teammate's agent, a scheduled routine), so a routine can pick up where the last session stopped without a CSV on anyone's disk.

A row is JSON you shape. RouterGrowth adds four things on top:

- **Identity.** Every row is keyed on a person (email, then LinkedIn URL, phone, or a handle with its platform, such as `instagram:janedoe`) or, in a company table, a domain. Writing the same person twice merges into one row, whichever identity you name them by.
- **A suppression list.** Anyone on it is never sent to again, on any channel a row ties them to. The check runs on the server before every send.
- **Touches.** Every successful send is recorded and stamps the rows of the people it reached (`touches`, `last_touch_at`, `last_touch_channel`).
- **Saved results.** An enrichment saved into a row is not bought again: a repeat of it in the last 30 days is answered from the row for free.

Tables are free to use. A workspace holds up to 25,000 rows and 20 tables pay per call, 100,000 on Plus, 500,000 on Pro and 2,000,000 on Scale, with at most 50,000 rows and 16 KB of data per row.

## Turn tables on

Tables are off until a workspace turns them on, and a workspace with them off works exactly as before: no table tools, no checks on its sends. Turning them on changes how sends behave (suppressed people and repeat first messages are blocked), so it is a choice the workspace makes.

```text
GET  /v1/features                         {"tables": {"enabled": false, ...}}
POST /v1/features   {"tables": true}      on; false turns them off again
```

Or `routergrowth features --enable tables`, or the switch on the dashboard's Billing page. Turning them on also indexes the workspace's earlier sends, so the repeat guard knows who was already contacted. Turning them off keeps your tables and suppression list but stops using them. Until tables are on, every route below answers `403 forbidden`, and a run that passes `record` or `into` is refused before anything is spent.

## Nobody has to ask for a table

Agents create them as a side effect of the job. The rules they follow (they ship in the MCP server instructions and in [SKILL.md](/SKILL.md)):

- A list you will enrich, contact, monitor or hand over goes in a table. A one-off lookup does not.
- Name it after the job, `skincare-creators-fr`, with a one-line description, and tell the user the name once.
- The first write creates it: a run with `into`, or an upsert.
- Start every session with `tables` (free) and resume an existing table instead of starting a new one.

## Write rows

```text
POST /v1/tables/{name}/rows
```

```bash
curl -X POST https://api.routergrowth.com/v1/tables/skincare-creators-fr/rows \
  -H "Authorization: Bearer $ROUTERGROWTH_API_KEY" -H "Content-Type: application/json" \
  -d '{"description": "French skincare creators, 10k to 100k followers",
       "rows": [{"platform": "instagram", "handle": "janedoe", "followers": 22000, "status": "found"},
                {"name": "Lee Park", "email": "lee@acme.com", "linkedin_url": "https://www.linkedin.com/in/leepark"}]}'
```

Up to 500 rows per call. `status` and `next_at` are row fields, everything else is data. New values win, and empty ones never erase what a row holds (`"mode": "replace"` swaps the whole object instead). A top-level `status` in the body applies to the rows this call creates.

```json
{"table": "skincare-creators-fr", "created": true, "inserted": 2, "updated": 0, "unchanged": 0,
 "conflicts": [], "refused": [],
 "keys": {"inserted": ["instagram:janedoe", "lee@acme.com"], "updated": []},
 "also_in": [{"table": "beauty-q3", "count": 1, "by_status": {"sent_j0": 1}, "contacted": 1}],
 "hints": ["1 of these are already in 'beauty-q3', 1 of them contacted."]}
```

`also_in` names the other tables that already hold the same people. `refused` rows carry no identity the table keys on. `conflicts` are rows whose identities point at two different existing rows: nothing is written for them.

## Query rows

```text
GET  /v1/tables/{name}/rows?status=approved&due=true&fields=email,first_name&limit=50
POST /v1/tables/{name}/rows/query
```

```json
{"status": ["sent_j0"], "due": true,
 "where": {"email_status": "valid", "followers": {"gte": 10000}, "phone": {"exists": false}},
 "fields": ["email", "first_name", "j0_message_id"], "order": "-followers", "limit": 50}
```

`where` takes a value for equality or `{op: value}` with `eq`, `ne`, `in`, `nin`, `exists`, `empty`, `contains`, `gt`, `gte`, `lt`, `lte`. Numbers compare as numbers, even when stored as strings; dotted paths reach nested fields (`profile.followers`). The row fields `status`, `next_at`, `touches` and `last_touch_at` filter the same way, with times as ISO or relative (`-7d`). `due: true` keeps rows whose `next_at` has passed. Pages are 50 rows by default and 500 at most; pass `next_cursor` back as `cursor`. Keep `fields` short: a routine polling a table should not pull every column each time.

```text
GET /v1/tables               every table with row counts by status, and the quota
GET /v1/tables/{name}        counts by status, rows due, columns with fill rates, the latest rows
```

## Update, claim and delete

```text
PATCH /v1/tables/{name}/rows
```

```json
{"status": "approved", "limit": 30, "if_status": "approved", "set": {"status": "sending"}}
```

`set` holds `status`, `next_at` (ISO or `+4d`, `+12h`) and data fields; a `null` removes a field. Name rows with `keys` (a row's key, or any email, URL or `platform:handle` it carries), or with a filter and a `limit`. With `if_status` the status change is a claim: only rows still in that status change, and the answer lists exactly those, so two routines running at once never take the same rows.

```text
POST   /v1/tables/{name}/rows/delete     {"keys": [...]} or a filter with limit
DELETE /v1/tables/{name}/rows/{key}
DELETE /v1/tables/{name}?confirm=true     the table and its rows, for good; suppressions stay
PATCH  /v1/tables/{name}                  title, description, columns, rename
POST   /v1/tables                         create up front: {"name", "description", "key": "auto|domain|email|linkedin|phone|handle|field:<name>"}
```

## Link runs to rows

Two fields on [run](/docs/api/run) (and `batch_run` over MCP) connect the paid calls to the table.

`into` lands a list result as rows. Posts and comments become their authors, one row per creator with the matching posts kept under `signals`; people and companies merge by identity, so a row already there keeps its status. Watches take `into` too: every refresh lands its new records.

```json
{"capability": "social.search", "input": {"platform": "instagram", "query": "routine skincare", "limit": 50},
 "routing": {"max_cost": "0.60"}, "into": {"table": "skincare-creators-fr", "status": "found"}}
```

`record` links a run to one row. On success `set` updates the row and `save` copies result fields into it (`"all"`, or `{"result field": "row field"}`); on a miss `on_miss` applies. An unknown table or key fails before anything runs. A send linked to a row is also checked against every identity the row carries.

```json
{"capability": "email.send",
 "input": {"inbox_id": "inb_...", "to": ["lee@acme.com"], "subject": "...", "text": "..."},
 "routing": {"max_cost": "0.01"}, "idempotency_key": "skincare-creators-fr:lee@acme.com:j0",
 "record": {"table": "skincare-creators-fr", "key": "lee@acme.com",
            "set": {"status": "sent_j0", "next_at": "+4d"},
            "save": {"message_id": "j0_message_id", "thread_id": "j0_thread_id"}}}
```

Give every send tied to a row the idempotency key `<table>:<key>:<step>`: a routine retried after a crash replays the send instead of sending twice. The run's answer carries a `table` object with what was written.

## Enrichments are not bought twice

When an enrichment (`contact.find`, `contact.phone`, `person.enrich`, `company.enrich`, `company.funding`, `company.technologies`, `social.profile`) was saved into a row of the workspace with `record` or `into` in the last 30 days, the same enrichment for the same subject is answered from that row: status `succeeded`, charged nothing, no provider called, and a `source` naming the table, the row and the run it came from. It works across agents and tables, and for the subject rather than the exact input (`Jane`, `jane` and `https://acme.com` are the same `contact.find`). Pass `routing.fresh: true` to buy it again, or pin a provider. Results are never reused across sandbox and live keys.

## Suppressions

```text
GET  /v1/suppressions?kind=email&reason=bounce
POST /v1/suppressions          {"subjects": ["jane@acme.com", "spam.io", "instagram:bob"], "reason": "unsubscribe"}
POST /v1/suppressions/remove   {"subjects": ["jane@acme.com"]}
```

Subjects are emails, domains (every address on them), LinkedIn URLs, phones and `platform:handle` (or `@handle` with `platform`). Reasons: `unsubscribe`, `bounce`, `complaint`, `not_interested`, `manual`. A send to a suppressed person comes back `403 suppressed`, is kept as a `blocked` run, and charges nothing. When a row ties an email to a LinkedIn profile, suppressing either blocks both. Remove a suppression only when the person asked to hear from you again.

## Repeat first touch

A new conversation with someone the workspace already contacted on the same channel in the last 30 days comes back `409 repeat_touch`, blocked and not charged, naming the earlier run. Replies in a thread pass (`in_reply_to` for email, `chat_id` for LinkedIn, Instagram and WhatsApp), and so do your own addresses, where test sends go. With the user's say-so, `routing.allow_repeat: true` sends anyway.

```text
GET  /v1/outbound-guard
POST /v1/outbound-guard     {"repeat_touch_days": 14}      0 turns it off
```

## CSV

```text
GET  /v1/tables/{name}/export.csv
POST /v1/tables/{name}/import?status=new     the CSV as the body, first line the column names
```

Import upserts with the same rules as a write, 1 MB at most (about 5,000 rows), so a `leads.csv` from an earlier campaign becomes a table in one call.

## Tools

Over [MCP](/docs/quickstart-mcp), once tables are on (the tools are not listed before): `tables` reads (no arguments to list, `table` to describe, filters to query rows, `suppressions: true` for the list) and `table_write` writes (`action`: `upsert`, `update`, `delete`, `create`, `edit`, `drop`, `suppress`, `unsuppress`). Both are free.
