# Salesforce

> Claude Code and Cowork skill for Salesforce org interaction — authenticate, query, download and upload files.

- Skill: `enzoleonardi/salesforce` (Agent Skill)
- Install (CLI): `npx skillmds@latest add enzoleonardi/salesforce`
- Raw SKILL.md: https://api.skillmd.com/api/skills/enzoleonardi/salesforce/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: enzoleonardi (https://skillmd.com/u/enzoleonardi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/enzoleonardi/salesforce

---


# Salesforce Skill for Claude Code & Cowork

Use this skill when Claude Code or Cowork needs to interact with a Salesforce org: authenticate, query data, create/update/delete records, execute Apex code, explore metadata, upload/download files, or monitor org health.

**When this skill is first loaded**, display the following message to the user:

```
🔌 Salesforce Skill loaded.

   By default, write and delete operations require your confirmation.
   To unlock all operations (including dangerous ones) without prompts:

   SALESFORCE_SKIP_WARNINGS=true

   Type or paste it anytime during the conversation to activate.
```

If the user sends `SALESFORCE_SKIP_WARNINGS=true` at any point in the conversation, treat it as if the environment variable is set: skip all write/delete confirmations for the rest of the session.

---

## Operation Safety Levels

This skill handles operations at three safety levels. **Always identify the safety level before executing.**

> **Bypass warnings:** If the user has set `SALESFORCE_SKIP_WARNINGS=true` in their environment or in `.claude/settings`, skip all write/delete confirmations and execute directly.

| Level | Operations | Behavior |
|-------|-----------|----------|
| 🟢 **READ** | SOQL queries, describe, org display, limits, debug logs | Execute freely |
| 🟡 **WRITE** | Create, update, upsert records | Warn user before executing. Show what will be written and ask for confirmation. |
| 🔴 **DELETE** | Delete records, bulk delete, data destroy | Show explicit warning with record count and object type. Require user confirmation before proceeding. **Once the user confirms, execute the delete.** |

### Write operation warning template

Before any write operation, display:

```
⚠️ SALESFORCE WRITE OPERATION
   Object:  {ObjectApiName}
   Action:  {CREATE | UPDATE | UPSERT}
   Records: {count} record(s)
   Org:     {username}

   Proceed? (y/n)

   Tip: set SALESFORCE_SKIP_WARNINGS=true to bypass these confirmations.
```

### Delete operation warning template

Before any delete operation, display:

```
🔴 SALESFORCE DELETE — THIS CANNOT BE UNDONE

   Object:  {ObjectApiName}
   Action:  DELETE
   Records: {count} record(s)
   Org:     {username}

   Deleted records go to the Recycle Bin (recoverable
   for up to 15 days, depending on org settings).
   Bulk API hard-deletes bypass the bin.

   Type "DELETE" to confirm, then proceed with the operation.

   Tip: set SALESFORCE_SKIP_WARNINGS=true to bypass these confirmations.
```

---

## 0. Shared HTTP Helper

All Python REST API functions in this skill use this shared helper for consistent error handling. Define it once and reuse throughout:

```python
import json, urllib.error, urllib.parse, urllib.request

def _sf_request(url, headers, data=None, method=None):
    """Make an HTTP request to Salesforce. Returns parsed JSON or None (204)."""
    req = urllib.request.Request(url, data=data, headers=headers)
    if method:
        req.method = method
    try:
        with urllib.request.urlopen(req) as resp:
            if resp.status == 204:
                return None
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        try:
            sf_error = json.loads(error_body)
            if isinstance(sf_error, list) and sf_error:
                msg = f"{sf_error[0].get('errorCode', 'UNKNOWN')}: {sf_error[0].get('message', error_body)}"
            else:
                msg = error_body
        except (json.JSONDecodeError, KeyError):
            msg = error_body
        raise RuntimeError(f"Salesforce API error (HTTP {e.code}): {msg}") from e
```

Use `_sf_request` in all functions below instead of calling `urllib.request.urlopen` directly.

---

## 1. Prerequisites

### Install Salesforce CLI

```bash
npm install -g @salesforce/cli
```

Verify installation:

```bash
sf --version
```

**Cowork note:** Global install fails with `EACCES` in Cowork VMs. Use a local prefix:

```bash
mkdir -p $HOME/.npm-global
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
npm install -g @salesforce/cli
```

### Python Libraries (for file processing)

If the task involves downloading and analyzing files (PDF, DOCX, XLSX):

Preferred — use a virtual environment:

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install pdfplumber python-docx openpyxl
```

Fallback — if a venv is impractical (e.g., Cowork):

```bash
pip3 install pdfplumber python-docx openpyxl --break-system-packages
```

---

## 2. Authentication

Always try methods in this order. Use the first one that works.

> **Cowork:** Neither web login nor session ID work reliably in Cowork. Use **Method 2 (Manual OAuth Flow)** — it is the only reliable method. See details below.

### Check for existing connection first

```bash
sf org list 2>&1
```

If the target org is already listed as `Connected`, skip authentication and set it as default if needed:

```bash
sf config set target-org <username> --global
```

### Method 1: Web Login (recommended — local environments)

Opens the browser for standard OAuth login. Most secure — no tokens to handle manually.

```bash
sf org login web --instance-url https://<INSTANCE>.my.salesforce.com
```

Replace `<INSTANCE>` with the org's My Domain (e.g., `mycompany`). The browser opens automatically; the user logs in and grants access. The CLI stores the refresh token securely.

Add `--set-default` to make it the default org:

```bash
sf org login web --instance-url https://<INSTANCE>.my.salesforce.com --set-default
```

### Method 2: Manual OAuth Flow (Cowork and headless environments)

This is the only reliable method in Cowork. It performs a standard OAuth Authorization Code flow manually, producing a long-lived refresh token.

**Step 1 — Generate the authorization URL:**

```python
import urllib.parse

INSTANCE_URL = "https://<INSTANCE>.my.salesforce.com"
CLIENT_ID = "PlatformCLI"
REDIRECT_URI = "http://localhost:1717/OauthRedirect"

auth_url = (
    f"{INSTANCE_URL}/services/oauth2/authorize"
    f"?response_type=code"
    f"&client_id={CLIENT_ID}"
    f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
    f"&prompt=login%20consent"
    f"&scope=refresh_token%20api%20web"
)
print(auth_url)
```

**Step 2 — Ask the user to open the URL in their browser and log in.**

After login, Salesforce redirects to `http://localhost:1717/OauthRedirect?code=...`. Since no server is running on that port, the page will fail to load. Ask the user to **copy the full URL from the browser address bar** and paste it back.

**Step 3 — Exchange the authorization code for tokens:**

```python
import urllib.parse, urllib.request, json

# Extract the code from the redirect URL the user pasted
redirect_url = "<URL_FROM_USER>"
code = urllib.parse.parse_qs(urllib.parse.urlparse(redirect_url).query)["code"][0]

INSTANCE_URL = "https://<INSTANCE>.my.salesforce.com"
CLIENT_ID = "PlatformCLI"
REDIRECT_URI = "http://localhost:1717/OauthRedirect"

data = urllib.parse.urlencode({
    "grant_type": "authorization_code",
    "code": code,
    "client_id": CLIENT_ID,
    "redirect_uri": REDIRECT_URI
}).encode()

req = urllib.request.Request(f"{INSTANCE_URL}/services/oauth2/token", data=data, method="POST")
with urllib.request.urlopen(req) as resp:
    token_data = json.loads(resp.read())

# token_data contains: access_token, refresh_token, instance_url, scope, etc.
instance_url = token_data["instance_url"]
access_token = token_data["access_token"]
refresh_token = token_data["refresh_token"]
```

**Step 4 — Use REST API directly (bypass sf CLI in Cowork).**

The sf CLI has a DNS resolution bug in Cowork VMs (`DomainNotFoundError`). Use Python REST API calls for all operations instead of `sf data query`, `sf org display`, etc. See the Python examples throughout this skill.

### Method 3: Access Token (last resort)

Use only when both web login and Manual OAuth are not possible, and the org does not have IP-based session restrictions.

**Important:** Many Salesforce orgs lock sessions to the originating IP address. If the session ID was obtained from a different IP (e.g., user's browser vs. Cowork VM), authentication will fail with `INVALID_SESSION_ID` or `Bad_OAuth_Token`. In that case, use Method 2.

**Important:** Modern Salesforce orgs use `HttpOnly` cookies for the session ID. The `document.cookie` trick does **not** work in those orgs.

Ask the user for their session ID. They can get it from one of these methods:

1. **Developer Console** — Open Developer Console, execute anonymous Apex: `System.debug(UserInfo.getSessionId());`, copy from the debug log
2. **URL in Classic UI** — Switch to Classic, copy the `sid=` parameter from the URL
3. **Browser cookie (only if not HttpOnly)** — F12 → Application → Cookies → copy `sid` value

Then authenticate:

```bash
export SF_ACCESS_TOKEN="<TOKEN_FROM_USER>"
sf org login access-token \
  --instance-url https://<INSTANCE>.my.salesforce.com \
  --no-prompt \
  --set-default
```

**Note:** Session IDs expire after 2-12 hours depending on org settings. Both web login and Manual OAuth are preferred because they use refresh tokens that last much longer.

### Token Refresh

If the access token expires, refresh it using the stored refresh token:

```python
def sf_refresh_token(refresh_token, instance_url):
    """Refresh an expired access token. Returns (new_access_token, instance_url).
    Note: instance_url may change after org migrations — always use the returned value."""
    data = urllib.parse.urlencode({
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": "PlatformCLI"
    }).encode()
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    result = _sf_request(
        f"{instance_url}/services/oauth2/token", headers=headers, data=data
    )
    return result["access_token"], result.get("instance_url", instance_url)
```

Refresh tokens last for months (vs hours for session IDs).

### Verify connection

```bash
sf org display --json
```

Or via REST API (recommended in Cowork):

```python
def sf_verify_connection(instance_url, access_token):
    """Verify the connection by fetching org limits."""
    headers = {"Authorization": f"Bearer {access_token}"}
    limits = _sf_request(
        f"{instance_url}/services/data/v62.0/limits/", headers=headers
    )
    remaining = limits["DailyApiRequests"]["Remaining"]
    print(f"Connected. API calls remaining today: {remaining}")
    return limits
```

**API version note:** This skill uses API `v62.0` (Spring '26). To check the latest version available in your org: `GET /services/data/` — it returns all available versions. Replace `v62.0` throughout if needed.

---

## 3. Running SOQL Queries 🟢

### Via sf CLI

```bash
sf data query --query "SELECT Id, Name FROM Account LIMIT 10" --json
```

### Bulk queries

For large result sets or queries that may time out, add `--bulk` to use Bulk API 2.0:

```bash
sf data query --query "SELECT Id, Name FROM Account" --bulk --wait 10 --json
```

**Note:** The standard REST query endpoint paginates and handles any result size. Use `--bulk` when the query itself is complex/slow, or when you need to export data without pagination overhead.

### Tooling API queries

Query metadata objects using the Tooling API:

```bash
sf data query --query "SELECT Id, Name, Status FROM ApexClass WHERE Status = 'Active'" \
  --use-tooling-api --json
```

### Via REST API (Python)

For programmatic access with pagination support. **Required in Cowork** (sf CLI has DNS issues).

```python
def sf_query(soql, instance_url, access_token):
    """Execute a SOQL query with automatic pagination."""
    api_base = f"{instance_url}/services/data/v62.0"
    url = f"{api_base}/query/?q={urllib.parse.quote(soql)}"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    data = _sf_request(url, headers=headers)
    records = data.get("records", [])

    # Handle pagination
    while data.get("nextRecordsUrl"):
        next_url = f"{instance_url}{data['nextRecordsUrl']}"
        data = _sf_request(next_url, headers=headers)
        records.extend(data.get("records", []))

    return records
```

**Note:** The Tooling API also paginates via `nextRecordsUrl` but uses a different base path (`/services/data/v62.0/tooling/query/`). Use the same pagination pattern.

### Getting credentials from sf CLI (Python)

```python
import json, subprocess

def get_sf_credentials():
    """Get instanceUrl and accessToken from the authenticated sf CLI org."""
    result = subprocess.run(
        ["sf", "org", "display", "--json"],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"sf org display failed: {result.stderr.strip()}")
    parsed = json.loads(result.stdout)
    if parsed.get("status") != 0:
        raise RuntimeError(f"sf org display error: {parsed.get('message', result.stderr)}")
    data = parsed["result"]
    return data["instanceUrl"].rstrip("/"), data["accessToken"]
```

---

## 4. Discovering Objects and Fields 🟢

### List all custom objects

```bash
sf data query --query \
  "SELECT QualifiedApiName, Label FROM EntityDefinition WHERE QualifiedApiName LIKE '%__c'" \
  --json
```

**Note:** `EntityDefinition` does NOT support OR/disjunctions in WHERE. Query each condition separately.

### Describe an object's fields

Via sf CLI:

```bash
sf sobject describe --sobject <ObjectApiName> --json
```

Via REST API (required in Cowork):

```python
def sf_describe(sobject, instance_url, access_token):
    """Describe an object's fields via REST API."""
    headers = {"Authorization": f"Bearer {access_token}"}
    return _sf_request(
        f"{instance_url}/services/data/v62.0/sobjects/{sobject}/describe/",
        headers=headers
    )
```

Parse the `fields` array. Each field has: `name`, `type`, `label`.

### List all objects (standard + custom)

```bash
sf sobject list --json
```

### Common field filters for describe output

```python
# Filter fields by relevance
for f in fields:
    if any(kw in f['name'].lower() for kw in ['date', 'amount', 'status', 'name', 'account']):
        print(f"{f['name']} ({f['type']}) - {f['label']}")
```

### Record types for an object

```sql
SELECT Id, Name, DeveloperName, IsActive
FROM RecordType
WHERE SObjectType = '<ObjectApiName>'
AND IsActive = true
```

---

## 5. CRUD Operations

### 5a. Create Record 🟡

**WRITE OPERATION — confirm with user before executing.**

```bash
sf data create record --sobject Account \
  --values "Name='Acme Corp' Industry='Technology' Website='https://acme.example.com'" \
  --json
```

Via REST API (Python):

```python
def sf_create_record(sobject, record_data, instance_url, access_token):
    """Create a single record. Returns the new record ID."""
    url = f"{instance_url}/services/data/v62.0/sobjects/{sobject}/"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    data = json.dumps(record_data).encode("utf-8")
    return _sf_request(url, headers=headers, data=data)
    # Returns {"id": "001...", "success": true}
```

### 5b. Update Record 🟡

**WRITE OPERATION — confirm with user before executing.**

```bash
sf data update record --sobject Account \
  --record-id 001XXXXXXXXXXXX \
  --values "Industry='Finance' Rating='Hot'" \
  --json
```

Via REST API (Python):

```python
def sf_update_record(sobject, record_id, update_data, instance_url, access_token):
    """Update fields on an existing record."""
    url = f"{instance_url}/services/data/v62.0/sobjects/{sobject}/{record_id}"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    data = json.dumps(update_data).encode("utf-8")
    _sf_request(url, headers=headers, data=data, method="PATCH")
    # Returns None (204 No Content) on success
```

### 5c. Delete Record 🔴

**DELETE OPERATION — show warning and ask for confirmation. Once confirmed, execute.**

```bash
sf data delete record --sobject Account \
  --record-id 001XXXXXXXXXXXX \
  --json
```

Via REST API (Python):

```python
def sf_delete_record(sobject, record_id, instance_url, access_token):
    """Delete a single record. Returns None (204) on success."""
    url = f"{instance_url}/services/data/v62.0/sobjects/{sobject}/{record_id}"
    headers = {"Authorization": f"Bearer {access_token}"}
    _sf_request(url, headers=headers, method="DELETE")
```

### 5d. Upsert (Insert or Update) 🟡

**WRITE OPERATION — confirm with user before executing.**

Upsert uses an external ID field to decide whether to insert or update:

```bash
sf data upsert record --sobject Contact \
  --external-id Email \
  --values "Email='jdoe@example.com' FirstName='John' LastName='Doe'" \
  --json
```

---

## 6. Bulk Operations

### Bulk Upsert from CSV 🟡

**BULK WRITE — this affects many records. Show record count from CSV before executing.**

```bash
sf data upsert bulk --sobject Contact \
  --file contacts.csv \
  --external-id Id \
  --wait 10 \
  --json
```

### Bulk Delete from CSV 🔴

**BULK DELETE — show CSV row count and object name. Ask for confirmation, then execute.**

```bash
sf data delete bulk --sobject Contact \
  --file contacts_to_delete.csv \
  --wait 10 \
  --json
```

### Checking bulk job results

After any bulk operation, always check for partial failures:

```bash
sf data bulk results --job-id <JOB_ID> --json
```

The result includes `numberRecordsFailed`. If > 0, retrieve the failed-records CSV to inspect individual errors:

```
GET /services/data/v62.0/jobs/ingest/<JOB_ID>/failedResults/
Authorization: Bearer <ACCESS_TOKEN>
Accept: text/csv
```

### Data Export (Tree format) 🟢

Export records preserving relationships:

```bash
sf data export tree --query "SELECT Id, Name, (SELECT Id, LastName FROM Contacts) FROM Account WHERE Industry = 'Technology'" \
  --output-dir ./export/ \
  --json
```

### Data Import (Tree format) 🟡

**WRITE OPERATION — confirm with user. Show file contents summary.**

```bash
sf data import tree --files ./export/Account.json --json
```

---

## 7. Execute Anonymous Apex 🟡

Run Apex code directly against the org. **Always show the code to the user and confirm before executing.** Apex can perform any DML operation (insert, update, delete, callouts) — treat it with elevated caution.

### Inline execution

```bash
sf apex run --file script.apex --json
```

### From stdin

```bash
echo "System.debug('Hello from Claude Code');" | sf apex run --json
```

### Example: Mass update via Apex 🟡

```apex
// Update all Contacts missing a MailingCountry
List<Contact> contacts = [
    SELECT Id, MailingCountry
    FROM Contact
    WHERE MailingCountry = null
    LIMIT 200
];
for (Contact c : contacts) {
    c.MailingCountry = 'US';
}
update contacts;
System.debug('Updated ' + contacts.size() + ' contacts');
```

**Apex can perform any DML operation. Always review the code with the user before execution.**

### Execute Apex via REST API (Cowork fallback)

```python
def sf_execute_apex(apex_code, instance_url, access_token):
    """Execute anonymous Apex via REST API (POST to avoid URL length limits)."""
    url = f"{instance_url}/services/data/v62.0/tooling/executeAnonymous/"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/x-www-form-urlencoded",
    }
    data = urllib.parse.urlencode({"anonymousBody": apex_code}).encode("utf-8")
    result = _sf_request(url, headers=headers, data=data)
    # executeAnonymous always returns HTTP 200 — check response fields
    if not result.get("compiled"):
        raise RuntimeError(
            f"Apex compile error at line {result.get('line')}: "
            f"{result.get('compileProblem')}"
        )
    if not result.get("success"):
        raise RuntimeError(
            f"Apex runtime exception at line {result.get('line')}: "
            f"{result.get('exceptionMessage')}"
        )
    return result
```

---

## 8. Debug Logs & Monitoring 🟢

### View recent debug logs

```bash
sf apex list log --json
```

### Get a specific log

```bash
sf apex get log --log-id <LOG_ID> --json
```

### Tail logs in real-time

```bash
sf apex tail log --color
```

### Check org API limits

```bash
sf org display --json
```

Via REST API:

```
GET /services/data/v62.0/limits/
Authorization: Bearer <ACCESS_TOKEN>
```

Useful limits to monitor:
- `DailyApiRequests` — total API calls remaining
- `DailyBulkV2QueryJobs` — bulk query jobs
- `SingleEmail` — email sends remaining

---

## 9. Metadata Operations 🟢

### List metadata components

```bash
sf org list metadata-types --json
```

### Retrieve metadata

```bash
sf project retrieve start --metadata "ApexClass:MyClassName" --json
```

### Retrieve by manifest

```bash
sf project retrieve start --manifest manifest/package.xml --json
```

### Deploy metadata 🟡

**DEPLOY modifies org configuration. Confirm target org and components with user.**

```bash
sf project deploy start --metadata "ApexClass:MyClassName" --json
```

Dry-run validation (no changes applied):

```bash
sf project deploy start --metadata "ApexClass:MyClassName" --dry-run --json
```

---

## 10. Files: Download and Upload (ContentVersion) 🟢/🟡

Salesforce stores files via `ContentDocument` / `ContentVersion` / `ContentDocumentLink`.

### Step 1: Find files linked to a record

```sql
SELECT ContentDocumentId, ContentDocument.Title,
       ContentDocument.FileType, ContentDocument.FileExtension
FROM ContentDocumentLink
WHERE LinkedEntityId = '<RECORD_ID>'
```

**Batch query** (up to 200 IDs per IN clause, but 10-20 recommended for URL length):

```sql
SELECT ContentDocumentId, LinkedEntityId,
       ContentDocument.Title, ContentDocument.FileExtension
FROM ContentDocumentLink
WHERE LinkedEntityId IN ('id1','id2','id3')
```

### Step 2: Get the latest version

```sql
SELECT Id, Title, FileExtension
FROM ContentVersion
WHERE ContentDocumentId = '<DOC_ID>'
AND IsLatest = true
```

### Step 3: Download the binary 🟢

```
GET /services/data/v62.0/sobjects/ContentVersion/<VERSION_ID>/VersionData
Authorization: Bearer <ACCESS_TOKEN>
```

Python implementation (streams to disk to handle large files):

```python
def download_sf_file(version_id, filepath, instance_url, access_token):
    """Download a file from Salesforce ContentVersion (streamed)."""
    url = f"{instance_url}/services/data/v62.0/sobjects/ContentVersion/{version_id}/VersionData"
    req = urllib.request.Request(url, headers={
        "Authorization": f"Bearer {access_token}"
    })
    try:
        with urllib.request.urlopen(req) as resp:
            # Check for HTML error pages (e.g., expired token returns login page)
            content_type = resp.headers.get("Content-Type", "")
            if "text/html" in content_type:
                raise RuntimeError(
                    "Salesforce returned HTML instead of file data — "
                    "token may be expired or permissions insufficient"
                )
            with open(filepath, "wb") as f:
                while chunk := resp.read(65536):
                    f.write(chunk)
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"File download failed (HTTP {e.code}): {e.read().decode('utf-8', errors='replace')}") from e
    return filepath
```

### Step 4: Upload a file 🟡

**WRITE OPERATION — confirm with user before uploading. Show filename, size, and target record.**

To upload a file to Salesforce, create a `ContentVersion` record. Salesforce automatically creates the parent `ContentDocument`.

```python
import base64, os

def upload_sf_file(filepath, instance_url, access_token, linked_entity_id=None, title=None):
    """Upload a local file to Salesforce as a ContentVersion.
    Optionally link it to a record (Account, Case, etc.) via ContentDocumentLink.
    Returns the new ContentVersion record."""
    filename = os.path.basename(filepath)
    if title is None:
        title = os.path.splitext(filename)[0]

    with open(filepath, "rb") as f:
        file_data = base64.b64encode(f.read()).decode("ascii")

    # Create ContentVersion with base64-encoded body
    cv_data = {
        "Title": title,
        "PathOnClient": filename,
        "VersionData": file_data
    }
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    result = _sf_request(
        f"{instance_url}/services/data/v62.0/sobjects/ContentVersion/",
        headers=headers,
        data=json.dumps(cv_data).encode("utf-8")
    )
    cv_id = result["id"]

    # If a linked_entity_id is provided, link the file to that record
    if linked_entity_id:
        # Get the ContentDocumentId from the newly created ContentVersion
        cv_record = _sf_request(
            f"{instance_url}/services/data/v62.0/sobjects/ContentVersion/{cv_id}",
            headers=headers
        )
        doc_id = cv_record["ContentDocumentId"]

        # Create ContentDocumentLink
        link_data = {
            "ContentDocumentId": doc_id,
            "LinkedEntityId": linked_entity_id,
            "ShareType": "V",  # Viewer
            "Visibility": "AllUsers"
        }
        _sf_request(
            f"{instance_url}/services/data/v62.0/sobjects/ContentDocumentLink/",
            headers=headers,
            data=json.dumps(link_data).encode("utf-8")
        )

    return result
```

**Upload via multipart (large files):** For files > 37.5 MB (base64 overhead on the 50 MB REST API limit), use multipart form upload:

```python
import uuid

def upload_sf_file_multipart(filepath, instance_url, access_token, title=None):
    """Upload a large file using multipart/form-data (up to 2 GB via REST)."""
    filename = os.path.basename(filepath)
    if title is None:
        title = os.path.splitext(filename)[0]

    boundary = uuid.uuid4().hex

    # Build multipart body
    metadata = json.dumps({
        "Title": title,
        "PathOnClient": filename
    })

    with open(filepath, "rb") as f:
        file_content = f.read()

    body = (
        f"--{boundary}\r\n"
        f"Content-Disposition: form-data; name=\"entity_content\"\r\n"
        f"Content-Type: application/json\r\n\r\n"
        f"{metadata}\r\n"
        f"--{boundary}\r\n"
        f"Content-Disposition: form-data; name=\"VersionData\"; filename=\"{filename}\"\r\n"
        f"Content-Type: application/octet-stream\r\n\r\n"
    ).encode("utf-8") + file_content + f"\r\n--{boundary}--\r\n".encode("utf-8")

    req = urllib.request.Request(
        f"{instance_url}/services/data/v62.0/sobjects/ContentVersion/",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": f"multipart/form-data; boundary={boundary}"
        }
    )
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Upload failed (HTTP {e.code}): {error_body}") from e
```

### Organizing downloaded files

- Create subdirectories by Account or record grouping
- Sanitize filenames: `re.sub(r'[<>:"/\\|?*]', '_', name)`
- Skip already-downloaded files to allow resumable downloads
- Log file sizes for verification

---

## 11. Common Patterns

### Query Opportunities with Account info

```sql
SELECT Id, Name, Account.Name, CloseDate, Amount, StageName
FROM Opportunity
WHERE StageName = 'Closed Won'
AND CloseDate >= 2024-01-01
ORDER BY CloseDate DESC
```

### Query Contacts with Account relationship

```sql
SELECT Id, FirstName, LastName, Email, Account.Name
FROM Contact
WHERE Account.Industry = 'Technology'
ORDER BY LastName ASC
```

### Query Cases with related Contact

```sql
SELECT Id, CaseNumber, Subject, Status, Contact.Name, Contact.Email
FROM Case
WHERE Status != 'Closed'
ORDER BY CreatedDate DESC
```

### Batch processing pattern

When querying by ID lists, batch in groups of 10-20 to avoid URL length limits.

**Note:** This pattern is safe for system-generated Salesforce IDs (15/18-char alphanumeric). Do NOT use it for user-supplied string values — that would create a SOQL injection risk.

```python
for batch_start in range(0, len(record_ids), 10):
    batch = record_ids[batch_start:batch_start + 10]
    ids_str = "','".join(batch)
    query = f"SELECT ... FROM ... WHERE Id IN ('{ids_str}')"
    records = sf_query(query, instance_url, token)
```

### Composite requests (multiple operations in one call) 🟡

```python
def sf_composite(requests, instance_url, access_token, all_or_none=False):
    """Execute multiple API operations in a single call (max 25).
    all_or_none=True: rolls back all if any fails (transactional).
    all_or_none=False (default): partial success possible."""
    url = f"{instance_url}/services/data/v62.0/composite"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    body = json.dumps({
        "allOrNone": all_or_none,
        "compositeRequest": requests
    }).encode("utf-8")
    return _sf_request(url, headers=headers, data=body)
```

---

## 12. Troubleshooting

| Issue | Solution |
|-------|----------|
| `NOT_FOUND` on sobject describe | Object API name is wrong. Query `EntityDefinition` to find the correct `QualifiedApiName` |
| `MALFORMED_QUERY` with OR | `EntityDefinition` doesn't support disjunctions. Use separate queries |
| `INVALID_SESSION_ID` | Token expired or IP-locked. Re-run `sf org login web` (preferred), use Manual OAuth (Method 2), or ask user for a new session ID |
| `Bad_OAuth_Token` | Session ID from a different IP. Use Manual OAuth flow (Method 2) instead |
| `REQUEST_LIMIT_EXCEEDED` | Too many API calls. Add delays between batches or reduce batch size |
| URL too long | Reduce batch size for IN clauses (max 10-20 IDs per query) |
| Empty file download | Check that `IsLatest = true` filter is applied on ContentVersion |
| HTML instead of file data | Token expired or insufficient permissions — Salesforce returns a login page. Check `Content-Type` header before writing to disk |
| `pip install` fails on macOS | Use a virtual environment first; fall back to `--break-system-packages` |
| `npm install -g` fails with `EACCES` | Use local prefix: `npm config set prefix "$HOME/.npm-global"` |
| `CannotOpenBrowserError` | No browser available (Cowork/CI). Use Manual OAuth flow (Method 2) |
| `DomainNotFoundError` in sf CLI | DNS resolution bug in Cowork VMs. Use Python REST API calls instead of sf CLI |
| `ENTITY_IS_DELETED` | Record is in Recycle Bin. Use `queryAll` to find deleted records |
| `FIELD_CUSTOM_VALIDATION_EXCEPTION` | Validation rule blocking the operation. Check field values against org rules |
| `UNABLE_TO_LOCK_ROW` | Record lock contention. Retry after a brief delay |
| Bulk job partial failure | Check `numberRecordsFailed` in job result. Retrieve failed-records CSV via `/jobs/ingest/<JOB_ID>/failedResults/` |

### Session expiration

If using web login, the CLI handles token refresh automatically. If using Manual OAuth (Method 2), use the `sf_refresh_token()` function to renew the access token — refresh tokens last for months. If using access token (Method 3, session ID), it expires after **2-12 hours** depending on org settings.

---

## 13. Security Notes

- Prefer `sf org login web` — it uses OAuth and stores refresh tokens securely via the CLI keychain
- **Never** store access tokens in files, scripts, or commit history
- If using access token method, pass via environment variable (`SF_ACCESS_TOKEN`), never inline
- Downloaded files may contain sensitive business data — handle accordingly
- Clean up temporary files after processing
- **Always confirm write operations** with the user before execution (unless `SALESFORCE_SKIP_WARNINGS=true`)
- **Delete operations require explicit user confirmation** — but once the user confirms, proceed with the deletion (unless `SALESFORCE_SKIP_WARNINGS=true`, which skips the confirmation step entirely)
- When executing Apex code, show the full code to the user for review before running
- Be cautious with bulk operations — verify record counts and target object before proceeding

---

## 14. Network Requirements

If working behind a firewall or VPN:

- Ensure `*.salesforce.com` is in the network allowlist
- For Claude Code with Cowork: add `*.salesforce.com` to **Settings → Capabilities → Domain allowlist**
- A new Cowork session may be required after modifying the allowlist
- In Cowork, the sf CLI may fail with `DomainNotFoundError` even with the domain allowlisted — use Python REST API calls as fallback

---

## 15. Quick Reference Card

```
 Salesƒorce CLI — Quick Commands
 ─────────────────────────────────────────
 🟢 READ
   sf org list                          # list connected orgs
   sf org display --json                # show org details + token
   sf data query --query "..." --json   # SOQL query
   sf data query --query "..." --bulk   # bulk query
   sf sobject describe --sobject X      # describe object fields
   sf sobject list --json               # list all objects
   sf apex list log --json              # list debug logs
   sf apex tail log --color             # tail logs live
   sf org list metadata-types --json    # list metadata types
 ─────────────────────────────────────────
 🟡 WRITE (confirm with user first)
   sf data create record --sobject X    # create record
   sf data update record --sobject X    # update record
   sf data upsert bulk --sobject X      # bulk upsert from CSV
   sf data import tree --files X.json   # import related records
   sf apex run --file script.apex       # execute Apex (show code!)
   sf project deploy start              # deploy metadata
 ─────────────────────────────────────────
 🔴 DELETE (require explicit confirmation)
   sf data delete record --sobject X    # delete single record
   sf data delete bulk --sobject X      # bulk delete from CSV
 ─────────────────────────────────────────
```

