Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Reverse Engineering — macOS Desktop & Electron Apps

When the target is a desktop app (Slack, Notion, Granola, VS Code, etc.) that stores data locally and syncs with a backend. The API is often undocumented; the app itself is your best source.

This is Layer 6 of the reverse-engineering docs:

  • Layer 1: Transport1-transport — TLS, headers, WAF bypass
  • Layer 2: Discovery2-discovery — web bundles, Apollo cache
  • Layer 3: Auth & Runtime3-auth — credentials, sessions
  • Layer 4: Content4-content — HTML scraping
  • Layer 5: Social Networks5-social — people, relationships
  • Layer 6: Desktop Apps (this file) — macOS, Electron, local state, unofficial APIs
    • electron.md — Electron deep dive: asar extraction, token files, CrossAppAuth, feature flags

When to Use This Approach

TargetApproach
Web app (browser-based)Layers 1–4 — bundles, GraphQL, cookies
Desktop app with local dataThis doc — app bundle + Application Support
Hybrid (web + desktop client)Both — auth may live in desktop, API is same

Desktop apps often reuse the same backend API as their web counterpart. The desktop client just embeds a token or session that the web version would get from a browser cookie flow. If you find the token, you can call the API directly from Python — no headless browser, no TLS fingerprint games.


Identify the App Stack

Is it Electron?

# Check for the telltale structure
ls -la /Applications/SomeApp.app/Contents/Resources/
# Look for: app.asar (bundled JS) or app/ (unpacked)

Electron apps ship:

  • app.asar — compressed archive of the app’s JS/HTML
  • Resources/ — icons, native modules
  • Chromium runtime inside Frameworks/

Find the app support directory

macOS apps store user data under:

~/Library/Application Support/<AppName>/

Common subdirs:

DirectoryWhat it contains
*.json (supabase, stored-accounts, local-state)Auth tokens, config, feature flags
Cache/, Code Cache/Chromium cache (less useful)
Local Storage/, IndexedDB/WebStorage — sometimes has SQLite DBs
Session Storage/Ephemeral state
blob_storage/Binary blobs
*.json (cache-v6, state)Entity cache — synced from backend, often the gold

Auth: Steal the Token

Desktop apps must persist auth somewhere. The user is logged in; the app survives restarts. Find where.

Common patterns

File patternTypical content
supabase.json, auth.json, tokens.jsonJWT access_token, refresh_token
stored-accounts.jsonAccount list, sometimes with session data
Cookies (SQLite)HTTP-only cookies — harder to extract
KeychainmacOS Keychain — use security find-generic-password

Extraction pattern

from pathlib import Path
import json

APP_SUPPORT = Path.home() / "Library" / "Application Support" / "Granola"

def get_token() -> str:
    with open(APP_SUPPORT / "supabase.json") as f:
        data = json.load(f)
    tokens = json.loads(data["workos_tokens"])  # nested JSON string
    return tokens["access_token"]

Tokens often live in nested JSON strings — the outer file is JSON, but some values (like workos_tokens) are themselves JSON strings. Parse twice.

Token lifetime

Desktop app tokens are often refreshed by the app when it’s running. If your skill gets 401, the user needs to open the app to refresh. Document this.


Discovery: App Bundle → API Endpoints

The app’s bundled JS contains every API endpoint it calls.

1. Find the app bundle

# macOS: find by name
mdfind "kMDItemDisplayName == 'Granola*'"

# Or known paths
ls /Applications/Granola.app/Contents/Resources/app.asar

2. Extract strings from the bundle

# If app.asar exists, unpack or search it
npx asar extract /Applications/Granola.app/Contents/Resources/app.asar /tmp/granola-app

# Or just run strings on the binary
strings /Applications/Granola.app/Contents/MacOS/Granola | grep -E "https://|api\.|/v1/|/v2/"

3. Search for endpoint patterns

PatternWhat you’ll find
https://api.Base API URLs
https://notes.Web app / docs URLs (often same backend, different frontend)
/v1/, /v2/Versioned API paths
get-documents, get-entity-setEndpoint names — these are your operations

4. Infer request shape from usage

Once you have endpoint names, search the bundle for where they’re called:

grep -r "get-entity-set\|get-entity-batch" /tmp/granola-app/

The surrounding code often shows the request body shape: { entity_type: "chat_thread" }.


Discovery: Local Cache → Data Model

The app syncs entities from the backend into a local cache. That cache is your schema discovery.

Find the cache file

Look for large JSON files or SQLite DBs in Application Support:

ls -la ~/Library/Application\ Support/Granola/
# cache-v6.json    <- 800KB, entities inside
# local-state.json <- feature flags, config

Parse the structure

import json
from pathlib import Path

cache_path = Path.home() / "Library/Application Support/Granola/cache-v6.json"
data = json.loads(cache_path.read_text())

state = data.get("cache", {}).get("state", {})
entities = state.get("entities", {})

# What entity types exist?
print(entities.keys())  # ['chat_thread', 'chat_message']

Infer relationships

From the cache structure:

ObservationImplication
chat_thread.data.grouping_key == "meeting:{doc_id}"Thread is linked to document
chat_message.data.thread_id == thread.idMessage belongs to thread
entity.type == "chat_thread"API has entity_type parameter

The cache gives you:

  • Entity types — what to ask the API for
  • Relationships — how to filter and join
  • Field names — request/response shape

API Probing: Confirm and Call

You have a token and a list of endpoints. Now validate.

1. Reuse existing transport

If the API is behind a plain origin (no CloudFront WAF), urllib often works:

from urllib.request import Request, urlopen
import json, gzip

def api_post(token: str, endpoint: str, body: dict):
    req = Request(
        f"https://api.granola.ai{endpoint}",
        data=json.dumps(body).encode(),
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept-Encoding": "gzip",
        },
        method="POST",
    )
    with urlopen(req, timeout=30) as r:
        raw = r.read()
        if r.headers.get("Content-Encoding") == "gzip":
            raw = gzip.decompress(raw)
        return json.loads(raw)

If you get 403, try httpx with HTTP/2 (see 1-transport).

2. Probe each endpoint

Start with the simplest call:

# List entities — what does the API return?
resp = api_post(token, "/v1/get-entity-set", {"entity_type": "chat_thread"})
# -> {"data": [{"id": "...", "workspace_id": "...", "created_at": "..."}], "entity_type": "chat_thread"}

3. Batch fetch for full data

The “set” endpoint usually returns IDs + minimal metadata. The “batch” endpoint returns full entities:

resp = api_post(token, "/v1/get-entity-batch", {
    "entity_type": "chat_thread",
    "entity_ids": ["uuid-1", "uuid-2"],
})
# -> {"data": [{"id": "...", "data": {"grouping_key": "meeting:doc-id", ...}}, ...]}

The data field on each entity is where the app-specific payload lives.


End-to-End Flow: Granola Example

  1. Auth~/Library/Application Support/Granola/supabase.jsonworkos_tokens.access_token
  2. DocumentsPOST /v2/get-documents (existing), POST /v1/get-documents-batch
  3. TranscriptPOST /v1/get-document-transcript
  4. PanelsPOST /v1/get-document-panels (AI summaries)
  5. Chat threadsPOST /v1/get-entity-set + get-entity-batch with entity_type: "chat_thread"
  6. Chat messages — same with entity_type: "chat_message"
  7. Linkchat_thread.data.grouping_key == "meeting:{document_id}" ties a thread to a meeting

Web URLs (from meeting summaries): https://notes.granola.ai/t/{thread_id} — same IDs as API.


API + Cache: Two Connections for Desktop Apps

Desktop apps that sync with a backend often have two data sources:

SourceWhereWhen to use
APINetwork call with tokenFresh data, full transcripts, works when online
CacheLocal file (JSON, SQLite) the app writesInstant, offline, token expired, or fallback

The app syncs entities into a local cache; that cache is often readable without the token. You can offer both as connections and let the caller choose.

Connection model

connections:
  api:
    description: "Live API — token from app, freshest data"
  cache:
    description: "Local cache — instant, works offline (reads app's cache file)"

Operations declare connection: api or connection: cache. Some operations may support both; others (e.g. get_meeting with full transcript) may be API-only if the cache doesn’t store transcripts.

When cache is enough

OperationAPICache
list_meetingsYes — paginated from serverYes — state.documents (may be stale)
list_conversationsYesYes — entities.chat_thread filtered by grouping_key
get_conversationYesYes — entities.chat_message by thread_id
get_meetingYes — full transcript + panelsPartial — cache may have docs but not transcript text

Implementation pattern

CACHE_PATH = Path.home() / "Library" / "Application Support" / "Granola" / "cache-v6.json"

def load_cache() -> dict:
    with open(CACHE_PATH) as f:
        return json.load(f)

def cmd_list_conversations_from_cache(document_id: str) -> list:
    data = load_cache()
    threads = (data.get("cache", {}).get("state", {}).get("entities", {}) or {}).get("chat_thread", {})
    target_key = f"meeting:{document_id}"
    out = []
    for tid, t in threads.items():
        if (t.get("data") or {}).get("grouping_key") != target_key:
            continue
        out.append({...})
    return out

Source param: api | cache | auto

For operations that support both, add a source param:

  • api — live call only (default)
  • cache — local file only
  • auto — try API, fall back to cache on 401/network error

This gives offline resilience without requiring the user to pick a connection up front.

Pure-cache skills (WhatsApp, Copilot Money)

Some desktop apps have no documented API — the app syncs internally and we only read the local DB. Those are “cache-only” by necessity:

SkillData sourcePattern
WhatsAppChatStorage.sqliteCache-only
Copilot MoneyCopilotDB.sqliteCache-only
Granolaapi.granola.ai + cache-v6.jsonAPI + cache

Subagent Strategy for Exploration

When the codebase is large or you need to search broadly:

  1. Launch an explore subagent with the app path, cache path, and bundle path.
  2. Tasks: Extract API URLs from app.asar, parse cache JSON structure, identify entity types and relationships.
  3. Deliverable: Findings report with endpoints, auth location, data model.

Then implement the skill using those findings. The subagent does the tedious search-and-document step; you do the clean integration.


Checklist: New Desktop App Skill

StepAction
1Find the app: mdfind or ls /Applications/
2Check for Electron: app.asar in Resources
3Locate Application Support: ~/Library/Application Support/<AppName>/
4Find auth: grep for token, access_token, Bearer in JSON files
5Find cache: large JSON or SQLite with entities, state, cache
6Parse cache: entity types, relationships, field names
7Extract endpoints: strings on binary or unpack asar, grep for https://, /v1/
8Probe API: get-entity-set, get-entity-batch or equivalent with token
9Implement: same patterns as web skills — operations, adapters, error handling

Real-World Examples

SkillDiscovery pathAPI + cache
skills/granola/supabase.json token, cache-v6.json entities, app.asar → get-entity-set/batch, grouping_key for meeting→thread linkYes — api/cache/auto via source param
skills/whatsapp/ChatStorage.sqliteCache-only (no API)
skills/copilot-money/CopilotDB.sqliteCache-only (no API)