The Paper Room

Sheet Workflows

A workflow reads a range from one Google Sheet, applies a transform, and writes the result — on a schedule or on demand. Try it in the web app, or drive it through the REST API.

Describe it in plain English

Instead of picking a mode and filling in fields by hand, describe what you want once — "count orders per rep and write the totals to Summary" — and POST /api/workflows/compileturns it into a structured, editable spec (mode + fields), grounded against your actual column names so it won't invent ones that don't exist. This is a one-time AI parse (1 credit) — the resulting workflow itself runs on schedule with whatever cost its mode implies, not the compile cost, every time.

curl https://thepaperroom.co/api/workflows/compile \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "count orders per rep", "columns": ["Rep", "Order ID", "Amount"]}'

Modes: only ai_transform costs credits

Every mode below is picked with the mode field on create. Six of the seven modes are exact, deterministic Go code (or a plain HTTP fetch, for api_call) with no model call — the credit gate (RunGated) only ever wraps the AI path, so scheduling a daily aggregate, formula, or api_call job costs nothing no matter how often it runs.

aggregateFree

Group rows by 1–2 columns and count/sum/average/min/max another column — a pure Go computation, no AI involved.

Fields: groupByColumn, groupByColumn2 (optional), aggregateColumn, aggregateOp (count | sum | avg | min | max)

dedupeFree

Flag duplicate rows by one or more key columns (case-insensitive match), keeping every row and marking later duplicates.

Fields: dedupeKeyColumns (comma-separated column names)

lookupFree

Join the source sheet against a second lookup sheet by matching key columns, pulling in specified columns — like VLOOKUP as a scheduled job.

Fields: lookupSpreadsheetId, lookupRange, lookupKeyColumn, lookupSourceKeyColumn, lookupReturnColumns

cleanupFree

Apply deterministic text ops (trim, uppercase, lowercase, title-case, phone-format) to selected columns.

Fields: cleanupColumns, cleanupOps — both comma-separated and applied to every selected column uniformly, so don't mix e.g. phone with a Name column

formulaFree

Compute a new column from a spreadsheet-style arithmetic expression referencing other columns as {Column Name} — +, −, ×, ÷, ^, parentheses. A bad reference or division by zero shows #ERROR on that row rather than failing the whole run.

Fields: formulaExpression (e.g. "{Price} * {Quantity} * 1.1"), formulaOutputColumn

api_callFree (no AI — it's an HTTP fetch)

GET a URL per row (placeholders substituted from {Column Name}, capped at 200 rows/run) and pull one field out of the JSON response into a new column. Requests to loopback/private/link-local addresses are blocked server-side.

Fields: apiCallUrl (e.g. "https://api.example.com/lookup?id={Customer ID}"), apiCallResponsePath (dot-path, optional), apiCallOutputColumn

gmail_import1 credit per new email (capped at 20/run)

Polls your connected Gmail inbox (read-only) for messages matching a search query, extracts CRM-ready fields from each with AI, and appends new rows to the destination sheet — never overwrites, and never re-imports a message it's already processed.

Fields: gmailImportQuery (Gmail search syntax, e.g. "is:unread label:leads") — sourceSpreadsheetId/sourceRange are unused for this mode

outlook_import1 credit per new email (capped at 20/run)

The Microsoft Graph mirror of gmail_import — polls a connected Outlook/Microsoft 365 account (read-only) for messages matching a search query, extracts CRM-ready fields with AI, and appends new rows. Requires connecting Outlook separately from Google Sheets (see the account/workflows page).

Fields: outlookImportQuery (Microsoft Graph $search syntax, e.g. "isRead:false") — sourceSpreadsheetId/sourceRange are unused for this mode

email_campaignFree (no AI — a template fill + an HTTP send)

Sends a personalized email to every new recipient in the source range via your connected Gmail or Outlook account — never the same address twice, even across runs. Capped at 30 recipients/run, paced 1/second. Pair with a "once" schedule for a single scheduled send, or a recurring schedule for an evergreen campaign that only emails newly-added rows.

Fields: campaignSubject, campaignHtml (both support {{Column Name}}), campaignProvider ("gmail"|"outlook"), campaignRecipientColumn — doesn't support requireApproval

ai_transform3 credits per run

The one mode that calls the model — for transforms that need judgment (summarizing, categorizing, extracting) rather than exact logic. This is the only mode gated through the credit system; the four above never touch it.

Fields: description (plain-English instruction for the AI)

Approval gate

Set requireApproval: true and a run computes its output, holds it as pending_approval with a before/after diff, and waits — nothing is written to the destination sheet until you call POST /api/workflows/runs/{runId}/approve. Pending approvals auto-expire after 48 hours and default to rejected, never to a silent auto-write.

Notifications

Set notifyEmail (via your connected Google account), notifyOutlookEmail (via your connected Outlook account), and/or notifySlackWebhookUrl to get a message when a run finishes, errors, or needs approval — set more than one to notify multiple places at once. Both email paths are send-only (neither ever reads your mail); Slack is a plain incoming webhook POST, no OAuth app install needed. All are best-effort: a failed notification is logged but never fails the underlying workflow run.

Audit log

Every run — scheduled, manual, or an approval decision — is recorded with row counts and a before/after snapshot. Pull it via GET /api/workflows/audit-log, or add ?format=csv for a download, from the API or the web page.