---
name: productivity
description: General-purpose productivity tool integrations — Airtable, Google Workspace, Linear, Maps/OpenStreetMap, Notion, nano-pdf, OCR/documents, PowerPoint. Each tool has its own sub-section.
version: 1.0.0
category: productivity
tags: [airtable, google-workspace, linear, maps, notion, pdf, ocr, powerpoint, productivity]
---

# Productivity Toolbox

Omnibus skill for general-purpose productivity tools. Each tool is a self-contained sub-section.

> Absorbed from: airtable, google-workspace, linear, maps, notion, nano-pdf, ocr-and-documents, powerpoint, teams-meeting-pipeline (pipeline ops).

---

## 1. Airtable — airtable

**Trigger:** CRUD operations on Airtable records, table schemas, API access.

### MCP tools (preferred)
Use the built-in MCP tools for Airtable — they wrap the REST API directly:
- `mcp_airtable_list_bases`
- `mcp_airtable_list_tables`
- `mcp_airtable_describe_table`
- `mcp_airtable_list_records`
- `mcp_airtable_get_record`
- `mcp_airtable_search_records`
- `mcp_airtable_create_record`
- `mcp_airtable_update_records`
- `mcp_airtable_delete_records`
- `mcp_airtable_create_field`
- `mcp_airtable_update_field`
- `mcp_airtable_update_table`
- `mcp_airtable_list_comments`
- `mcp_airtable_create_comment`
- `mcp_airtable_upload_attachment`

### REST API via curl (fallback)
```bash
# List bases
curl -s -H "Authorization: Bearer $AIRTABLE_API_KEY" \
  "https://api.airtable.com/v0/meta/bases"

# List records
curl -s -H "Authorization: Bearer $AIRTABLE_API_KEY" \
  "https://api.airtable.com/v0/{baseId}/{tableId}" \
  -G --data-urlencode "maxRecords=100" \
  --data-urlencode "pageSize=100"
```

### Filter formula syntax
```
AND({Status}="Active", {Priority}>2)
```

### Common operations
```python
# Create record
mcp_airtable_create_record(
    baseId="appXXXXXXXX",
    tableId="tblXXXXXXXX",
    fields={"Name": "John", "Status": "Active"}
)

# Update records (up to 10)
mcp_airtable_update_records(
    baseId="appXXXXXXXX",
    tableId="tblXXXXXXXX",
    records=[{"id": "recXXXX", "fields": {"Status": "Done"}}]
)

# Upsert pattern (find then create/update)
records = mcp_airtable_list_records(
    baseId="appXXXXXXXX",
    tableId="tblXXXXXXXX",
    filterByFormula=f"{{Email}}='{email}'"
)
if records:
    mcp_airtable_update_records(...)
else:
    mcp_airtable_create_record(...)
```

---

## 2. Google Workspace — google-workspace

**Trigger:** Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python.

### gws CLI (VPS — preferred)
```bash
# OAuth setup
python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py --auth-url
# User pastes URL, authorizes, pastes redirect URL back
python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py --auth-code "CODE_FROM_REDIRECT"

# Gmail search
gws gmail search "from:admin@lfcs.com.au subject:invoice"

# Gmail read
gws gmail read <message_id>

# Gmail send
gws gmail send --to "admin@lfcs.com.au" --subject "Daily Report" --body "Content here"

# Calendar
gws calendar list --limit 10
gws calendar event create --summary "Site Visit" --start "2026-06-10T09:00:00" --end "2026-06-10T10:00:00"

# Drive
gws drive search "TCE Liverpool"
gws drive list --limit 20
```

### OAuth troubleshooting
When Drive/Gmail API returns "Caller does not have required permission":
1. Download new `client_secret.json` (not enough — old refresh token still routes to wrong project)
2. Re-authenticate to get fresh project-bound token:
   ```bash
   python /root/.hermes/skills/productivity/google-workspace/scripts/setup.py --auth-url
   python /root/.hermes/skills/productivity/google-workspace/scripts/setup.py --auth-code "CODE_FROM_REDIRECT_URL"
   ```
3. Verify: `python -c "from googleapiclient.discovery import build; ...` (direct API call)

**IMPORTANT — `setup.py --auth-url` hardcodes all scopes:** The bundled setup.py at `google-workspace/scripts/setup.py` requests 10 scopes (gmail.send, drive, sheets, contacts, etc.) regardless of what the job actually needs. For a readonly Gmail+Calendar digest, build the auth URL manually:

```python
import urllib.parse
params = {
    "response_type": "code",
    "client_id": "890850960489-5hejo1gd3apvpn1nu1183ohk4u9rlnev.apps.googleusercontent.com",
    "redirect_uri": "http://localhost:1",
    "scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly",
    "access_type": "offline",
    "prompt": "consent"
}
url = "https://accounts.google.com/o/oauth2/auth?" + urllib.parse.urlencode(params)
print(url)
```

Then run `--auth-code` with the returned code. The existing token at `~/.hermes/google_token.json` is NOT overwritten — the new token is saved in the same path by setup.py.

**Testing-mode OAuth apps (unverified):** If Google returns `access_denied — app has not completed verification`, the app is in testing mode. Add the user's email as a test user: Google Cloud Console → APIs & Services → OAuth consent screen → Test users → Add Users. This bypasses verification for internal/test users only.

**`localhost:1` redirect is expected:** When the OAuth flow redirects to `http://localhost:1/?code=...`, the browser shows "connection refused" — this is correct. The user should copy the code from the URL bar (after `?code=`). `ERR_UNSAFE_PORT` is a browser-level warning about port 1, not an error with the flow.

**Token account tracking:** The current `google_token.json` has an empty `account` field — the setup.py script does not record which Google account was authenticated. If this matters (e.g. running multiple Google accounts), check the `email` field returned by `https://www.googleapis.com/oauth2/v2/userinfo` after token exchange.

**pm2 list hangs in foreground:** `pm2 list --no-color` hangs indefinitely in non-interactive/foreground contexts. Use `pm2 jlist` (JSON output) instead, or pipe through `timeout 10`. This affects any health-check or monitoring script that calls `pm2 list`.

### Enable APIs in Google Cloud Console
Required APIs: `drive.googleapis.com`, `sheets.googleapis.com`, `gmail.googleapis.com`, `docs.googleapis.com`, `calendar-json.googleapis.com`
Project: `hermes-lfcs` — enable at: `https://console.cloud.google.com/apis/library/{api-name}.googleapis.com?project=hermes-lfcs`

Full end-to-end: see `references/google-workspace-end-to-end.md`

---

## 3. Linear — linear

**Trigger:** Manage issues, projects, teams via Linear GraphQL API.

### API setup
```bash
export LINEAR_API_KEY="lin_api_xxxxx"
export LINEAR_TEAM_ID="TEAM_ID"
```

### GraphQL via curl
```bash
# List issues
curl -s -X POST https://api.linear.app/graphql \
  -H "Authorization: Bearer $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ issues(first: 20, filter: { team: { id: { eq: \"$LINEAR_TEAM_ID\" } } }) { nodes { id title state { name } priority } } }"}'

# Create issue
curl -s -X POST https://api.linear.app/graphql \
  -H "Authorization: Bearer $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { issueCreate(input: { teamId: \"'$LINEAR_TEAM_ID'\", title: \"Fix login bug\" }) { success issue { id title } } }"}'

# Update issue state
curl -s -X POST https://api.linear.app/graphql \
  -H "Authorization: Bearer $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { issueUpdate(id: \"ISSUE_ID\", input: { stateId: \"STATE_ID\" }) { success } }"}'
```

### Key entities
- Teams, Projects, Issues, Comments, Labels, Cycles
- State workflow: Backlog → Todo → In Progress → Done

---

## 4. Maps — maps

**Trigger:** Geocoding, POIs, routing, timezones via OpenStreetMap/OSRM.

### CLI tools
```bash
# Geocode
curl -s "https://nominatim.openstreetmap.org/search" \
  -G --data-urlencode "q=123 Main St Sydney NSW" \
  -H "User-Agent: Hermes/1.0" | jq .

# Reverse geocode
curl -s "https://nominatim.openstreetmap.org/reverse" \
  -G --data-urlencode "lat=-33.8688" --data-urlencode "lon=151.2093" \
  -H "User-Agent: Hermes/1.0" | jq .

# Routing (OSRM)
curl -s "https://router.project-osrm.org/route/v1/driving/-33.8688,151.2093;-33.8600,151.2300" \
  | jq '.routes[0].distance, .routes[0].duration'

# Timezone
curl -s "https://ip-api.com/json/203.45.67.89" | jq '.timezone'
```

### Python
```python
import requests

def geocode(address):
    r = requests.get("https://nominatim.openstreetmap.org/search",
                     params={"q": address, "format": "json"},
                     headers={"User-Agent": "Hermes/1.0"})
    return r.json()[0] if r.json() else None

def route(start_lon, start_lat, end_lon, end_lat):
    r = requests.get(f"https://router.project-osrm.org/route/v1/driving/{start_lon},{start_lat};{end_lon},{end_lat}")
    return r.json()["routes"][0]
```

---

## 5. Notion — notion

**Trigger:** Pages, databases, markdown via Notion API + ntn CLI.

### ntn CLI (preferred)
```bash
# Auth
ntn auth login

# Search
ntn search "project notes"

# Read page
ntn page <page-id>

# Create page
ntn page create --parent <database-id> --title "New Item" --content "Markdown content"

# Database query
ntn db query <database-id> --filter 'property=="value"'
```

### API via curl
```bash
export NOTION_TOKEN="secret_xxxxx"

# Search
curl -s -X POST https://api.notion.com/v1/search \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2022-06-28" \
  -H "Content-Type: application/json" \
  -d '{"query": "LFCS"}'

# Retrieve page
curl -s https://api.notion.com/v1/pages/<page_id> \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2022-06-28"

# Create page
curl -s -X POST https://api.notion.com/v1/pages \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2022-06-28" \
  -H "Content-Type: application/json" \
  -d '{"parent": {"database_id": "<db-id>"}, "properties": {"title": {"title": [{"text": {"content": "New Item"}}]}}}'
```

---

## 6. nano-pdf — nano-pdf

**Trigger:** Edit PDF text/typos/titles via nano-pdf CLI (natural language prompts).

### Installation
```bash
pip install nano-pdf
```

### Usage
```bash
# Edit PDF text
nano-pdf edit "Fix the typo on page 2: ' reciept' → 'receipt'" input.pdf output.pdf

# Update title
nano-pdf edit "Change title to 'Invoice #1234'" input.pdf output.pdf

# Extract text
nano-pdf extract input.pdf
```

### Python API
```python
from nanopdf import PDFEditor

editor = PDFEditor("input.pdf")
editor.edit("Fix typo on page 3: 'occurence' → 'occurrence'")
editor.save("output.pdf")
```

---

## 7. OCR & Documents — ocr-and-documents

**Trigger:** Extract text from PDFs/scans (pymupdf, marker-pdf).

### pymupdf (PyMuPDF)
```python
import fitz  # PyMuPDF

def extract_text_from_pdf(path):
    doc = fitz.open(path)
    text = ""
    for page in doc:
        text += page.get_text()
    return text

def extract_tables_from_pdf(path):
    doc = fitz.open(path)
    for page in doc:
        tables = page.find_tables()
        for table in tables:
            print(table.extract())
```

### marker-pdf (better for scanned documents)
```bash
pip install marker-pdf
marker /path/to/scan.pdf --outdir /output/dir/
```

### Tesseract (CLI)
```bash
# OCR image to text
tesseract input.png output.txt

# OCR PDF
pdftoppm -png input.pdf page
tesseract page-1.png output
```

---

## 8. PowerPoint — powerpoint

**Trigger:** Create, read, edit .pptx decks, slides, notes, templates.

### python-pptx
```python
from pptx import Presentation
from pptx.util import Inches, Pt

# Create
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])  # Title and Content
title = slide.shapes.title
title.text = "Project Status"
body = slide.placeholders[1]
body.text = "• Update 1\n• Update 2\n• Update 3"
prs.save("status.pptx")

# Read
prs = Presentation("existing.pptx")
for slide in prs.slides:
    print(f"Slide {slide.slide_number}")
    for shape in slide.shapes:
        if hasattr(shape, "text"):
            print(shape.text)

# Edit
prs = Presentation("existing.pptx")
slide = prs.slides[0]
for shape in slide.shapes:
    if shape.has_text_frame:
        for para in shape.text_frame.paragraphs:
            for run in para.runs:
                if "OLD" in run.text:
                    run.text = run.text.replace("OLD", "NEW")
prs.save("updated.pptx")
```

### Extract notes
```python
prs = Presentation("deck.pptx")
for slide in prs.slides:
    if slide.has_notes_slide:
        notes = slide.notes_slide.notes_text_frame.text
        print(f"Slide {slide.slide_number}: {notes}")
```

---

## 9. Teams Meeting Pipeline — teams-meeting-pipeline

**Trigger:** Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions.

### CLI commands
```bash
# Check pipeline status
hermes teams-pipeline status

# Summarize a meeting
hermes teams-pipeline summarize <meeting-id>

# Replay a job
hermes teams-pipeline replay <job-id>

# Manage Graph subscriptions
hermes teams-pipeline subscriptions list
hermes teams-pipeline subscriptions create --type "calendar.changes" --expiry 4230m
hermes teams-pipeline subscriptions delete <subscription-id>
```

### Pipeline architecture
- Microsoft Graph webhook → Hermes event → job queued → summary generated → delivery
- Subscriptions expire after 4230 minutes (max). Cron job renews before expiry.

### Common issues
- Subscription expired → re-create with `subscriptions create`
- Job failed → `replay` with same meeting ID
- Missing summary → check Graph API permissions

---

## Pitfalls (all tools)

1. **Airtable:** Filter formulas use field names with spaces in brackets `{Field Name}`, not `field_name`
2. **Google Workspace:** Token bound to OAuth project — re-auth if "Caller does not have permission" error
3. **Linear:** GraphQL only — no REST. Use `issues(first: N)` pagination for large result sets.
4. **Notion:** Page IDs vs Database IDs — different API endpoints. Use `search` to find the ID first.
5. **nano-pdf:** Not a full PDF editor — only handles text layer, not images or vector graphics.
6. **OCR:** pymupdf for text PDFs, marker-pdf for scanned/image PDFs, tesseract for raw images.
7. **PowerPoint:** `.pptx` is a zip file — use `python-pptx` library, don't try to edit raw XML.