# /syncro — Syncro PSA ticket management

Create, update, close, comment on, and bill tickets in Syncro PSA.

## Usage

```
/syncro                                  Show open tickets summary
/syncro ticket <number>                  View ticket details + comments
/syncro create <customer> <subject>      Create new ticket
/syncro update <number> <status>         Update ticket status
/syncro close <number>                   Close/resolve a ticket
/syncro comment <number> <text>          Add a comment to a ticket
/syncro bill <number>                    Add billable time and create invoice
/syncro search <query>                   Search tickets by subject/customer
/syncro customers <query>                Search customers
/syncro move-appointment <customer>      Find and reschedule an existing appointment
/syncro estimate <customer> <subject>    Create ticket + linked estimate with line items and private purchase notes
/syncro schedules <customer>             List recurring invoice schedules for a customer
/syncro schedule <id>                    View a schedule's template and line items
/syncro assets <customer>                List a customer's RMM assets (read)
/syncro asset <id>                       Asset detail + patches + installed applications
/syncro asset-update <id>                Rename / fix serial / edit an asset (preview-gated)
/syncro move-asset <id> <folder>         Move asset to a policy folder (needs token scope - see below)
/syncro rmm-alerts [<customer>]          List live RMM alerts; mute/clear by id
/syncro deploy-agent <customer> <host>   Push the Syncro RMM agent to a machine via GuruRMM
```

## API Configuration

**Base URL:** `https://computerguru.syncromsp.com/api/v1`
**API Key:** per-user tokens in SOPS vault — see "Get API key" below
**Rate limit:** 180 requests/minute per IP
**Docs:** https://api-docs.syncromsp.com/
**Full endpoint catalog:** `.claude/standards/syncro/api-reference.md` — every Syncro call
(~180 endpoints, 38 resource types), verified against the live spec + tenant 2026-07-10.
This file documents the core PSA + asset/RMM/agent-deploy workflows; the reference covers
the rest (contacts, contracts, leads, vendors, POs, products, payments, wiki, policy folders).

## Hard Rules (violations have occurred — no exceptions)

**Normal billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry` for routine billing.** Timers are an OUTLIER: use one ONLY if Mike explicitly requests a timer for a specific job, never for the normal billing loop. For all billable work (labor, warranty, internal), POST directly to `/tickets/<id>/add_line_item` with the correct `product_id`, `name`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`. The `name` field is required — Syncro returns `{"errors":"Name can't be blank"}` if omitted (verified 2026-05-21 on Cascades #32313).

**JSON payloads to curl: use heredoc with `--data-binary @-`, not `/tmp/*.json` files.** On Windows the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with `<<'JSON'` (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See `.claude/memory/feedback_tmp_path_windows.md` — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session).

**If any API call returns an unexpected response: STOP and report — do NOT try alternative endpoints, payload formats, or retries.** The Syncro API does not change between calls. An unexpected result means either the call failed cleanly (check the error field) or it succeeded with a known-quirky response shape (see Verified Response Shapes below). Experimenting with alternatives creates duplicates that cannot be cleaned up via API.

**Before any POST:** Always show the full payload to the user and wait for explicit confirmation. This applies to tickets, comments, line items, and invoices — including hidden/internal notes.

**After any ambiguous POST result** (null fields, jq error, curl error, timeout): Do NOT retry. GET the resource first to confirm whether the action succeeded. Syncro has no idempotency on any endpoint — one POST always creates one record. Duplicate tickets and comments cannot be deleted via API; comments require manual GUI removal.

**Ticket response shape:** `{"ticket": {...}}` — always use `.ticket.id`, never `.id`. The flat-object jq pattern silently returns nulls and looks like failure when it isn't.

**Billing:** Always ask for minutes and labor type before adding any line item. Never assume a default. **"Emergency" is a billing modifier, not a delivery channel** — if the user says "emergency" without specifying Remote / Onsite / In-Shop, you MUST ask. The delivery channel determines `price_retail` ($262.50 onsite, $225.00 remote/in-shop) and cannot be guessed.

**Emergency/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/<id>` and read `prepay_hours`. Emergency = time-and-a-half (×1.5), applied ONCE — never bill a separate regular + emergency line for the same hours. **No prepaid (`prepay_hours == 0`):** `26184` at qty = actual hours; set `price_retail` by delivery channel — **Onsite $262.50** (175×1.5, 26184's default), **Remote / In-Shop $225** (150×1.5, override price_retail). The rate carries the 1.5×; do NOT also ×1.5 the qty. **Prepaid (`prepay_hours > 0`):** still use `26184`, at qty = actual hours **× 1.5** (premium goes in the quantity since prepaid debits by quantity; invoice nets $0, block debits hours×1.5). e.g. 1.5 emergency hrs prepaid → `26184` @ 2.25. (Rule updated 2026-05-27 by Mike: prepaid emergency uses `26184`, NOT the old `26118`×1.5 — keeps the line labeled emergency + mapping right in QuickBooks. Original ×1.5-not-additive lesson: #32203 Desert Auto Tech 2026-04-23, Winter.)

**`prepay_hours` is ONLY reliable from `GET /customers/{id}` — the customer SEARCH/LIST endpoint lies.** `GET /customers?query=...` (and any list endpoint) returns `prepay_hours: null` (or stale) even when the customer HAS a block. **NEVER read `prepay_hours` from a search/list result, and NEVER assert "no prepaid block" / "real charge" / a dollar total in a preview built from search data.** Before ANY billing preview or decision that mentions prepay, do a full `GET /customers/{id}` and read `.customer.prepay_hours` from THAT response. If you only have search data, fetch the full record first — do not guess. The billing-flow Step-1 GET is mandatory and must happen BEFORE the preview, not just before the invoice. (Recurring miss flagged by Mike 2026-06-23: previews repeatedly said "$300, no block," then the block surfaced at invoice time and netted $0 — Dataforth, Grabb & Durando #32455.)

**Prepaid customers — ALL billing (not just emergency):** `GET /customers/<id>` → `prepay_hours` before creating ANY invoice for a prepaid customer. When you bill a prepaid customer using a billable labor product (remote / onsite / in-shop / web), Syncro automatically deducts from their prepay block and the invoice total shows $0.00. The line item name is annotated "- Applied X Prepay Hours". This is correct behavior — do NOT treat a $0.00 invoice as an error. Verify the deduction by re-fetching `customer.prepay_hours` after invoicing and confirming it dropped by `quantity`.

**`9269129` (Labor - Prepaid Project Labor) is EXEMPT — it does NOT deduct from prepay blocks:** Despite the name, this product is categorized as Exempt Labor at $0.00 and contains no prepay-deduction logic. Billing a prepaid customer with this product results in a $0.00 invoice AND no block decrement — silent accounting drift. Discovered 2026-05-04 (see `feedback_syncro_labor_type.md`). NEVER use `9269129` for normal or prepaid work. Only use it if explicitly directed. The correct approach for prepaid customers is a billable labor product matching the delivery channel (remote / onsite / in-shop / web).

**Line-item `name` AND `price_retail` MUST both be fetched from the product and set explicitly.** Run `GET /products/<id>` and capture `.product.name` → use as `name`, `.product.price_retail` → use as `price_retail`. Neither populates automatically via API. Omitting `name` returns `{"errors":"Name can't be blank"}`. Omitting `price_retail` leaves the line at $0.00 (verified 2026-04-23 on #32203 and 2026-05-21 on Cascades #32313).

**Always pass `"taxable": false` explicitly on labor line items.** Labor products are configured with `taxable: false` in Syncro, but `add_line_item` via API does not inherit the product's taxable setting — it posts the line item as `taxable: true` regardless. Always include `"taxable": false` in the payload to match the product's configured value.

**`product_category` is a CONTROLLED VOCABULARY — never invent one, never invoice a line blank.** Syncro categories are an admin-defined set; the CATEGORY column on every invoice line drives QuickBooks item mapping and revenue reporting. A blank category = an uncategorized line that breaks both. Two hard rules:
- **Never make up a category string.** Use ONLY a value that already exists in the tenant. To see the live set, enumerate distinct `product_category` across products (do NOT hardcode — it changes): `for p in $(seq 1 N); do curl -s "${BASE}/products?per_page=100&page=$p&api_key=${API_KEY}"; done | jq -r '.products[].product_category' | sort -u`. Known good values include `Labor`, `Software`, `Hardware`, `Product`, `Service`/`Services`, `Subscriptions`, `Contract`, `Discount`, `Exempt Labor`, `Prepay Hours`, `Other`, `Deposit`, `Default`. If none clearly fits, **ASK — do not guess**.
- **Never invoice a line whose product has `product_category: null`.** Before billing, `GET /products/<id>` and check `.product.product_category`. If it is `null`/blank, the product is mis-configured — STOP and flag it (a Windows/OS/software item → `Software`; a physical item → `Hardware`). Do not silently push the line out uncategorized. (Incident 2026-06-30, Cascades #32466/#32474 invoices 67887/67890: "Windows Pro Upgrade" product 23571919 has `product_category: null`, so every billed line showed a blank CATEGORY column. Winter corrected the records; the rule is here so the skill catches it next time.)

**`DELETE /schedules/{id}` destroys the recurring invoice template immediately — no confirmation, no undo.** Past generated invoices are unaffected but future billing stops. The schedule must be recreated manually with all line items if deleted accidentally. NEVER run destructive HTTP method probes against a live customer schedule — use ACG internal account (customer_id 15353550) for any testing. Incident: Russo Law Firm schedule 224454 deleted during API research 2026-05-26; recreated as 509659.

**Test articles — always prefix the subject/name with `[TEST]`.** Any ticket, estimate, appointment, or schedule created for testing or API research MUST have its subject or name prefixed with `[TEST]` (e.g. `[TEST] Schedule API research`, `[TEST] Estimate - hardware pricing`). This applies regardless of which customer account is used (including the ACG internal test account, customer_id 15353550). Test records must be instantly distinguishable from real customer work at a glance. If a test article was created without the prefix, PUT the subject to add it before continuing.

**Appointment dates — always verify day-of-week before the preview.** Day-of-week math is easy to get wrong. Before including any appointment date in a preview, run a live check and display the full day name alongside the date (e.g. "Saturday 2026-05-23", never just "2026-05-23"). The user confirms the day name at the preview step — if the name is wrong, the date is wrong. Incident: #32312 booked Sunday May 24 instead of Saturday May 23 (2026-05-21). Reported by Winter.

```bash
# Read Python command from identity.json (Phase 2 migration), fallback to 'py' if unavailable
PYTHON=$(jq -r '.python.command // "py"' "$IDENTITY_PATH" 2>/dev/null || echo "py")
$PYTHON -c "import datetime; d = datetime.date(YYYY, M, D); print(d.strftime('%A %Y-%m-%d'))"
```

**After every write operation, post a summary + link to #bot-alerts.** Every ticket created, updated, closed, or commented, every billing run, and every customer created posts a one-line alert to the team's live feed in Discord. This runs AFTER the write succeeds (never before — no alert for an action that didn't happen) and applies regardless of who runs the skill or where (workstation or the Discord bot). Read-only commands (list / view / search) post nothing. Full format, link mapping, and helper call are in "Post to #bot-alerts" below.

**Estimate task success criteria — do NOT consider the request fulfilled until ALL of the following are true:**
1. Every line item requested by the user has been added to the estimate and price-fixed via PUT (verify with GET /estimates/{id} — check `.estimate.line_items[]`)
2. Every line item has a corresponding private note on the linked ticket (hidden: true, do_not_email: true) containing: item name, source/retailer, cost, retail price, and any markup
3. The estimate total (subtotal + tax) matches the sum of all line items after recalc
4. The linked ticket subject starts with `Estimate - ` (verify with GET /tickets/{id} — check `.ticket.subject`). If it does not, PUT the ticket with the corrected subject before reporting done.
5. A bot alert has been posted to #bot-alerts

If any check fails, complete the missing step before reporting done. This rule fires on initial estimate creation AND on every subsequent "add X to the estimate" request. Incident: 2026-05-22, UPS added to estimate #7189 without a ticket note — caught by Winter.

**Emailing an invoice/estimate returns 200 "Email sent" even when it reached NOBODY — never
report delivery from the 200 alone.** `POST /invoices/{id}/email` and `/estimates/{id}/email`
take no recipient param; Syncro picks it from the record. If `customer.email` is null (and no
recipient contact is flagged), the send is a silent no-op that still returns HTTP 200. Before
emailing, confirm the customer has a valid `email`. Note `customer.email` is **UNIQUE
tenant-wide** — reusing one returns `{"success":false,"message":["Email has already been
taken"]}`; a `PUT /customers` that fails returns that error shape (not `{"customer":{...}}`),
so check `.success` on customer writes instead of assuming a silent no-op.

**Living test-findings log:** `.claude/standards/syncro/test-findings.md` records verified
behaviors/gotchas discovered while exercising the skill on the Howard Test sandbox
(customer 36118743). Consult + append to it as new API quirks surface — it is how the skill
avoids re-learning the same trap. Critical entries are folded into these Hard Rules and the
Verified Response Shapes table.

## Implementation

When invoked, use the Syncro REST API via `curl`. All requests include `?api_key=<key>` as query parameter (NOT in header — Syncro uses query param auth).

**Repo root resolution:** All scripts read `claudetools_root` from `.claude/identity.json` (set during machine onboarding). This eliminates cross-architecture path issues. The identity.json file is machine-specific (gitignored) and contains the absolute path to the ClaudeTools repo on each machine.

### Attribution rule (CRITICAL)

Every Syncro API call is attributed to the **owner of the API key**. Comments, line items, timer entries, and invoices created by the API are logged as the API user — regardless of who is running the command. So the skill MUST use a per-user API key that matches the actual tech running it, or comments will be misattributed.

**Preserve attribution on edits — do NOT reassign (Mike 2026-05-27):**
- **Correcting mis-billed labor** (a debug action) must keep the ORIGINAL tech's `user_id` (their commission). `update_line_item` preserves the existing `user_id`; a remove+add defaults the new line to the API-key owner — so set `user_id` to the original tech on `add_line_item`, or PUT it afterward. Determine the original tech from `.ticket.user_id` and the line's `.user_id`. Don't take a tech's commission just because the math was fixed by someone else.
- **Ticket ownership:** adding notes/labor or changing status does NOT change the ticket owner. Multiple techs routinely work one ticket. Only PUT a ticket's `user_id` (reassign owner) when explicitly asked; status PUTs send only `status`.

| identity.json user | Syncro user | user_id | vault path |
|---|---|---|---|
| `mike` | Michael Swanson | 1735 | `msp-tools/syncro` |
| `howard` | Howard Enos | 1750 | `msp-tools/syncro-howard` |
| `winter` | Winter Williams | 1737 | `msp-tools/syncro-winter` |

Keys live in the SOPS vault and are resolved by `.claude/scripts/syncro-env.sh` (source it — never hardcode a key). When `CLAUDETOOLS_REQUESTER_USER` is set (Discord bot threads), syncro-env.sh prefers the REQUESTER's key if one is vaulted, so bot-driven Syncro actions attribute to the person who asked; it falls back to the identity.json user otherwise. To add a new user: generate a token in Syncro → Admin → API Tokens, vault it at `msp-tools/syncro-<user>.sops.yaml`, and add a case to `_syncro_key_path()` in syncro-env.sh.

### Get API key

```bash
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
#
# The key is per-user on purpose: every action in Syncro is attributed to the key
# owner, so using someone else's key misattributes tickets, time, and invoices.
#
# NOTE: identity.json lives in the REPO (.claude/identity.json), not ~/.claude/.
# Reading ~/.claude/identity.json first is a known bug — it silently picks up a
# stale or absent file. syncro-env.sh resolves the repo copy from its own location.
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || {
    echo "[ERROR] Cannot resolve Syncro credentials — run onboarding / check the vault" >&2
    exit 1
}

BASE="$SYNCRO_BASE"
API_KEY="$SYNCRO_API_KEY"
REPO_ROOT="$CLAUDETOOLS_ROOT"

if [ -z "$API_KEY" ]; then
    echo "[ERROR] No Syncro API key for user '${SYNCRO_USER:-unknown}'" >&2
    exit 1
fi
```

### Ollama drafting

Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes.

**Availability check** — read endpoint from identity.json (no probing), reuse `$OLLAMA` for the rest of the session:

```bash
# Read Ollama endpoint from identity.json (Phase 2 migration: no curl probe)
OLLAMA=""
if [ -f "$IDENTITY_PATH" ] && command -v jq >/dev/null 2>&1; then
    OLLAMA=$(jq -r '.ollama.endpoint // .ollama.fallback // empty' "$IDENTITY_PATH" 2>/dev/null)
fi

# Fallback: probe if identity.json doesn't have ollama config yet (legacy machines)
if [ -z "$OLLAMA" ]; then
    if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
        OLLAMA="http://localhost:11434"
    elif curl -s -m 3 http://100.101.122.4:11434/api/tags >/dev/null 2>&1; then
        OLLAMA="http://100.101.122.4:11434"
    else
        OLLAMA=""  # Claude drafts directly
    fi
fi
```

**Draft call:**

```bash
# Write prompt to a workspace path both the Write tool and Git Bash agree on
# (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in
# Write vs Git Bash). Use $REPO_ROOT/.claude/tmp/ or pipe via heredoc.
# $REPO_ROOT is set in the "Get API key" section above.
PROMPT_FILE="$REPO_ROOT/.claude/tmp/ollama_prompt.txt"
mkdir -p "$(dirname "$PROMPT_FILE")"
cat > "$PROMPT_FILE" <<'ENDPROMPT'
<prompt content here>
ENDPROMPT

# Read Python command from identity.json (Phase 2 migration)
PYTHON=$(jq -r '.python.command // empty' "$IDENTITY_PATH" 2>/dev/null)
if [ -z "$PYTHON" ]; then
    # Fallback: auto-detect (legacy machines)
    for candidate in py python3 python; do
        if command -v "$candidate" >/dev/null 2>&1; then
            if "$candidate" -c "import sys; sys.exit(0)" >/dev/null 2>&1; then
                PYTHON="$candidate"
                break
            fi
        fi
    done
fi

if [ -n "$OLLAMA" ]; then
    DRAFT=$(PROMPT_FILE="$PROMPT_FILE" $PYTHON -c "
import os, urllib.request, json, sys
prompt = open(os.environ['PROMPT_FILE']).read()
body = json.dumps({
  'model': 'qwen3:14b',
  'messages': [{'role': 'user', 'content': prompt}],
  'stream': False,
  'think': False
}).encode()
res = json.loads(urllib.request.urlopen(
    urllib.request.Request('$OLLAMA/api/chat', body), timeout=60
).read())
print(res['message']['content'])
")
else
    echo "[INFO] Ollama unavailable — Claude will draft directly."
    DRAFT=""
fi
```

**When to use Ollama:**
- Comment body drafting (`/syncro comment`, `/syncro close`, billing resolution notes)
- Billing `description` field (line item billing narrative)
- Ticket initial description during `/syncro create`

**When NOT to use Ollama:**
- JSON field selection (product_id, quantity, price_retail) — Claude owns this; always fetch price_retail live from Syncro
- Read operations (GET)
- Auth, credential, or security decisions

#### Billing draft prompt template

```
You are a Syncro PSA billing assistant. Draft a resolution comment and billing description.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
TECH: <tech_name>
WORK DONE: <user description of work>
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>

Rules:
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — they do not render.
- Keep it professional and factual. No filler phrases.
- line_item_description is one plain-text line, billing-facing.

Return ONLY valid JSON, no prose before or after:
{
  "comment_subject": "Resolution",
  "comment_body": "<HTML with <br> line breaks>",
  "line_item_description": "<one line plain text>",
  "preview": "<2-3 sentence plain-text summary for tech review>"
}
```

#### Comment draft prompt template

```
You are a Syncro PSA tech assistant. Draft a ticket comment.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
NOTE: <user's note or description>
VISIBILITY: <"Internal only" | "Customer-visible">

Rules:
- Use <br> for line breaks. Do NOT use <ul> or <li>.
- Professional and factual. No filler.

Return ONLY valid JSON:
{
  "subject": "Update",
  "body": "<HTML with <br> line breaks>",
  "preview": "<plain text for tech review>"
}
```

#### Claude review checklist (always run before presenting to user)

Whether the draft came from Ollama or Claude wrote it directly:

1. `price_retail` was fetched live from `GET /products/<product_id>` → `.product.price_retail` and matches what will be shown to the user
2. `quantity` = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)
3. Computed total = `price_retail × quantity` — matches what was communicated to user
4. If labor_type is `emergency` and `prepay_hours > 0`: product must be `26184` (emergency item), qty must be actual_hours × 1.5 (premium in the quantity)
5. `comment_body` uses `<br>` — scan the string for `\n` (literal backslash-n) or bare newlines and replace with `<br>`. No `<ul>`/`<li>`.
6. No internal notes or credential data in a customer-visible comment body

If a check fails: correct it and note the fix in the preview so the user can see what changed.

#### Fallback behavior

If `OLLAMA` is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce `[INFO] Ollama unavailable — drafting directly.`

---

### Parsing Syncro API Responses

**Problem:** Syncro API responses contain unescaped control characters (U+0000 through U+001F) that break both `jq` and Python's `json.load()`. These characters appear in customer names, ticket descriptions, and other text fields.

**Symptoms:**
- `jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped`
- Python: `json.decoder.JSONDecodeError: Invalid control character`

**Solution:** Use `grep` and `sed` for simple field extraction instead of jq. For complex parsing where jq is unavoidable, preprocess with `tr -d '\000-\037'` to strip control characters (lossy but functional).

**Common Patterns:**

```bash
# Extract numeric ID from response (safe - works with control chars)
TICKET_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
TICKET_NUMBER=$(echo "$RESP" | grep -o '"number":[0-9]*' | head -1 | grep -o '[0-9]*')
CUSTOMER_ID=$(echo "$RESP" | grep -o '"customer_id":[0-9]*' | head -1 | grep -o '[0-9]*')

# Extract string field (use sed to extract between quotes)
# Example: "status":"New" → New
STATUS=$(echo "$RESP" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p')

# Extract decimal/float field
TOTAL=$(echo "$RESP" | grep -o '"total":"[0-9.]*"' | head -1 | grep -o '[0-9.]*')

# Fallback for complex parsing - strip control chars then use jq
CLEAN=$(echo "$RESP" | tr -d '\000-\037')
TICKET_ID=$(echo "$CLEAN" | jq -r '.ticket.id')
```

**When to use each approach:**
- **grep/sed** (preferred): Extracting numeric IDs, simple string fields, totals
- **jq with preprocessing** (only when necessary): Complex nested structures, arrays, multiple fields at once
- **Never retry jq on parse failure** — if jq fails once on a response, it will fail every time with the same input. Switch to grep/sed immediately.

---

### Adding a per-user key

1. User logs into Syncro → Admin → API Tokens → New (`/api_tokens/new`)
2. Type: Integration API Token (or Custom with all standard scopes: asset/customer/ticket/invoice/payment read+write+delete, worksheet add+manage+delete, chat + script.execute)
3. Copy the token once (Syncro only shows it on creation)
4. Encrypt to vault:
   ```bash
   cat > $VAULT_ROOT/msp-tools/syncro-<user>.sops.yaml <<YAML
   kind: api-key
   name: Syncro (<Full Name>)
   subdomain: computerguru
   api-base-url: https://computerguru.syncromsp.com/api/v1
   api-docs: https://api-docs.syncromsp.com/
   status: active
   owner: <user>
   syncro_user_id: <id>
   tags: [msp-tools, per-user]
   credentials:
       credential: <TOKEN>
   notes: Per-user Syncro API token for <Full Name>. Created YYYY-MM-DD.
   YAML
   # MUST run from vault root so sops picks up .sops.yaml
   (cd "$VAULT_ROOT" && sops --encrypt --in-place "msp-tools/syncro-<user>.sops.yaml")
   ```
5. Commit + push vault repo.

### Endpoints reference

#### Tickets

| Operation | Method | Endpoint | Body |
|---|---|---|---|
| List tickets | GET | `/tickets?status=<status>&per_page=25` | — |
| Get ticket | GET | `/tickets/<id>` | — |
| Create ticket | POST | `/tickets` | see full create workflow below |
| Update ticket | PUT | `/tickets/<id>` | `{"status": "In Progress", "priority": "..."}` |
| Delete ticket | DELETE | `/tickets/<id>` | — |

**Ticket statuses:** `New`, `In Progress`, `Waiting on Customer`, `Waiting on Vendor`, `Scheduled`, `Resolved`, `Invoiced`, `Closed`

**Priority format** (number-prefixed string): `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`
Default: `"2 Normal"`. Use `"4 Urgent"` for emergency/after-hours.

**Problem types (Issue Type dropdown — use closest match, else "Not determined"):**
`API`, `Email`, `Emergency Service`, `File Services / Permissions`, `Hardware`, `Maintenance`,
`New User / M365 Account Creation`, `New User / Workstation Deployment`, `Not determined`,
`Onsite`, `Other`, `Phone/VOIP`, `Remote`, `Security`, `Server Migration`, `Service Request`,
`Software`, `Website`

**Appointment types:**

| Name | ID | location_type |
|---|---|---|
| In Shop | 4321 | shop |
| Onsite | 4322 | customer |
| Phone Call | 4323 | pre_defined |
| Reminder | 193053 | manual_entry |
| Remote | 59289 | pre_defined |

**Tech user IDs:** Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760

#### Appointments

| Operation | Method | Endpoint | Notes |
|---|---|---|---|
| List (today) | GET | `/appointments?start_at=YYYY-MM-DD` | Filter by date; use `.summary` to match customer |
| Get | GET | `/appointments/<id>` | Returns `{"appointment": {...}}` |
| Create | POST | `/appointments` | Used in ticket creation flow (Call 3) |
| Move / edit | PUT | `/appointments/<id>` | Verified 2026-04-24 — updates `start_at`/`end_at` |
| Delete | DELETE | `/appointments/<id>` | Not yet verified |

**Finding an appointment by customer:** `GET /appointments?start_at=<date>` returns all appointments — filter client-side with `select(.summary | test("customer name"; "i"))` or `select(.ticket.customer_id == N)`. The `customer_id` query param does not filter correctly.

**Move workflow:**
1. `GET /appointments?start_at=<date>` — find appointment ID
2. Confirm new date/time with user
3. `PUT /appointments/<id>` with `{"start_at": "ISO8601", "end_at": "ISO8601"}`
4. Verify response: `.appointment.start_at` matches intended time

**Response shape:** `{"appointment": {...}}` — parse as `.appointment.id`, `.appointment.start_at`, etc.

---

### Ticket creation workflow (full — 3 API calls)

Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.

#### Step 1 — Gather inputs

Collect in one pass (do not ask field by field):

| # | Field | Notes |
|---|---|---|
| 1 | **Subject** | Brief title: reason for the ticket |
| 2 | **Issue Type** (`problem_type`) | From dropdown above; "Not determined" if unclear |
| 3 | **Priority** | "2 Normal" default; "4 Urgent" for emergencies |
| 4 | **Description** | Expanded detail — becomes the "Initial Issue" comment body |
| 5 | **Do Not Email** | Suppress customer notification on ticket create? (yes for internal/reminder tickets) |
| 6 | **Due Date** | ISO date |
| 7 | **Assigned Tech** | Who owns the ticket. Defaults to API key owner if not specified (mike → 1735, howard → 1750). MUST always be included in the POST payload — never omit. |
| 8 | **Contact** | Omit unless the ticket is opened by or specifically regarding a named contact. When omitted, Syncro assigns the customer's primary contact automatically. Only look up and set `contact_id` when the user names a specific person. |
| 9 | **Appointment Type** | Omit unless the user specifies one of: Remote, Onsite, In Shop, Phone Call, Reminder. If omitting, include the delivery type in the ticket subject line so it's visible on the calendar. |
| 10 | **Location** | Free text; usually blank unless onsite at non-primary address |
| 11 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment |
| 12 | **End Time** | Default: start + 90 minutes |
| 13 | **Asset** | Search `GET /customer_assets?customer_id=N&query=<name>` if a specific device is involved |

**Contact lookup (only when a specific contact is named):**

```bash
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | \
  jq '[.customer.contacts[] | {id, name, email}]'
```

Match by name, confirm with user, then include `contact_id` in the ticket POST. Never include `contact_id: null` — omit the field entirely when using the default.

#### Step 2 — Show preview and confirm

Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation.

```
TICKET PREVIEW
--------------
Customer:     <name>
Subject:      <subject>
Issue Type:   <problem_type>
Priority:     <priority>
Description:  <description>
Due Date:     <due_date>
Assigned To:  <tech name>
Contact:      <primary — Syncro default>  (or named contact if specified)
Do Not Email: <yes/no>

APPOINTMENT   (omit section if no appointment)
-----------
Type:         <type name>
Start:        <Weekday YYYY-MM-DD> at <HH:MM AM/PM>   ← day name verified with py datetime
End:          <Weekday YYYY-MM-DD> at <HH:MM AM/PM>  (90 min default)
Location:     <location or blank>

ASSET:        <asset name or none>

Confirm? (yes/no)
```

#### Step 3 — Execute (after confirmation)

**Call 1 — Create ticket:**

```bash
RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "customer_id": N,
  "subject": "...",
  "problem_type": "...",
  "status": "New",
  "priority": "2 Normal",
  "user_id": N,
  "due_date": "YYYY-MM-DD"
}
JSON
)
TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id')
CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id')
```

Omit `contact_id` unless a specific contact was named — Syncro assigns the primary automatically. Omit `asset_ids` unless an asset was identified. Omit `do_not_email` unless suppression was requested. Never include fields with null values. The `'JSON'` quoting on the heredoc suppresses `$` expansion inside the payload.

**Call 2 — Post initial description as "Initial Issue" comment:**

```bash
curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Initial Issue",
  "body": "<the full description>",
  "hidden": false,
  "do_not_email": true
}
JSON
# Parse: .comment.id  (NOT .id — see Hard Rules)
```

Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.

**Call 3 — Create appointment (only if start_at provided):**

```bash
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "ticket_id": N,
  "customer_id": N,
  "start_at": "ISO8601",
  "end_at": "ISO8601"
}
JSON
```

Omit `appointment_type_id` unless the user specifies Remote (59289), Onsite (4322), In Shop (4321), Phone Call (4323), or Reminder (193053). When omitting the type, ensure the ticket subject includes the delivery method so it's identifiable on the calendar. Omit `location` unless a non-standard location is specified.

Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed.

**Payload handoff: prefer heredoc with `--data-binary @-` and `<<'JSON'` quoting** — never use `/tmp/<file>.json` for piping payloads from the Write tool to curl. On Windows, the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so curl reads a different (or stale) file than Write created. Heredoc avoids the file handoff entirely, and the `'JSON'` quoting prevents bash from expanding `$` characters inside the payload (passwords, regex, jq queries, etc.). See `.claude/memory/feedback_tmp_path_windows.md` for the full failure mode.

---

### Verified Response Shapes

Every endpoint's response shape, verified against the live API. Parse exactly as shown — no guessing.

| Operation | Endpoint | Response shape | Key to parse |
|---|---|---|---|
| Create ticket | POST `/tickets` | `{"ticket": {...}}` | `.ticket.id`, `.ticket.number` |
| Update ticket | PUT `/tickets/{id}` | `{"ticket": {...}}` | `.ticket.status` |
| Add comment | POST `/tickets/{id}/comment` | `{"comment": {...}}` | `.comment.id` |
| Add line item | POST `/tickets/{id}/add_line_item` | **FLAT** `{"id": N, ...}` | `.id` |
| Update line item | PUT `/tickets/{id}/update_line_item` | `{"ticket_line_item": {...}}` | `.ticket_line_item.id` |
| Remove line item | POST `/tickets/{id}/remove_line_item` | `{"success": true, "message": ""}` | — |
| Create invoice | POST `/invoices` | `{"invoice": {...}}` | `.invoice.id`, `.invoice.total` |
| Get invoice | GET `/invoices/{id}` | `{"invoice": {"line_items": [...]}}` | `.invoice.line_items[].price` (not `price_retail`) |
| Delete invoice | DELETE `/invoices/{id}` | `{"message": "ID: We deleted # N."}` | — |
| Create appointment | POST `/appointments` | `{"appointment": {...}}` | `.appointment.id` |
| Update appointment | PUT `/appointments/{id}` | `{"appointment": {...}}` | `.appointment.start_at` |
| Delete appointment | DELETE `/appointments/{id}` | `{"message": "deleted"}` | — |
| Create estimate | POST `/estimates` | `{"estimate": {...}}` | `.estimate.id`, `.estimate.number` |
| Add estimate line item | POST `/estimates/{id}/line_items` | `{"estimate": {...}, "line_item": {"id": N, ...}}` | `.line_item.id` |
| Get estimate | GET `/estimates/{id}` | `{"estimate": {"line_items": [...]}}` | `.estimate.line_items[].price` |
| Delete estimate | DELETE `/estimates/{id}` | `{"message": "N: We deleted # NNNN. "}` | — |
| List schedules | GET `/schedules` | `{"schedules": [...], "meta": {...}}` | `.schedules[].id`, `.meta.total_entries` |
| Get schedule | GET `/schedules/{id}` | `{"schedule": {"lines": [...]}}` | `.schedule.id`, `.schedule.lines[].id` |
| Create schedule | POST `/schedules` | `{"schedule": {...}}` | `.schedule.id` |
| Update schedule | PUT `/schedules/{id}` | `{"schedule": {...}}` | `.schedule.next_run`, `.schedule.paused` |
| Delete schedule | DELETE `/schedules/{id}` | `{"success": "deleted"}` | — **NO CONFIRMATION — immediate** |
| Add schedule line | POST `/schedules/{id}/line_items` | `{"schedule_line_item": {...}}` | `.schedule_line_item.id` |
| Update schedule line | PUT `/schedules/{id}/line_items/{li_id}` | `{"schedule_line_item": {...}}` | `.schedule_line_item.quantity`, `.schedule_line_item.price_retail` |
| Delete schedule line | DELETE `/schedules/{id}/line_items/{li_id}` | `{"success": true}` | — |
| Create customer | POST `/customers` | `{"customer": {...}}` | `.customer.id` |
| Update customer | PUT `/customers/{id}` | **success** `{"customer": {...}}` / **error** `{"success": false, "message": [...]}` | check `.success` — email is unique tenant-wide |
| Create contact | POST `/contacts` | **FLAT** `{"id": N, "email": ...}` (NOT wrapped) | `.id` — GET-verify; null-parse ≠ failure |
| Update contact | PUT `/contacts/{id}` | **FLAT** `{"id": N, ...}` | `.id` — `primary`/`receives_invoices` flags are ignored via API |
| Create contract | POST `/contracts` | write OK but body does NOT echo the object | GET `/contracts` + match; never retry on null |
| Email invoice | POST `/invoices/{id}/email` | `{"message": "Email sent."}` | **200 even with NO recipient** — verify `customer.email` first |
| Email estimate | POST `/estimates/{id}/email` | `{"message": "Email sent"}` | **200 even with NO recipient** — verify `customer.email` first |
| Create lead | POST `/leads` | `{"lead": {...}}` | `.lead.id` |
| Create vendor | POST `/vendors` | `{"vendor": {...}}` | `.vendor.id` — no DELETE (persists) |
| Create/Update product | POST/PUT `/products/{id}` | `{"product": {...}}` | `.product.id` — `name`+`description` required; no DELETE |
| Create PO | POST `/purchase_orders` | `{"purchase_order": {...}}` | `.purchase_order.id` — no DELETE |
| Add PO line | POST `/purchase_orders/{id}/create_po_line_item` | `{"po_line_item": {...}}` | needs product `maintain_stock:true` |
| Create worksheet | POST `/tickets/{id}/worksheet_results` | `{"worksheet_result": {...}}` | `.worksheet_result.id` — template ids in `GET /tickets/settings` |
| Create policy folder | POST `/policy_folders` | `{"policy_folder": {...}}` | `.policy_folder.id` — `parent_id` REQUIRED (null → 422); full-scope token |
| Delete policy folder | DELETE `/policy_folders/{id}` | empty body / 200 | full-scope token only |

**Invoice GET line_items field names differ from ticket line_items:** `item` = product name, `name` = description, `price` = unit rate. Do not use `price_retail` when reading invoice line items.

**Estimate line items use the same field naming as invoice line_items:** `item` = product name, `name` = description, `price` = unit rate (not `price_retail`). The add-line-item endpoint is `POST /estimates/{id}/line_items` — NOT `add_line_item` (that path 404s). Response includes both the updated estimate and the created line_item as sibling keys.

**`start_at` in ticket POST is unreliable** — always create appointments via separate POST `/appointments`. Do not rely on `start_at`/`end_at` on the ticket object itself.

---

#### Comments

**Dead-end paths (all return 404 — do not probe):**
- `POST /ticket_comments` — top-level; GET works for listing but POST does not exist
- `POST /tickets/{id}/comments` (plural) — does not exist
- `POST /ticket_comments` with `ticket_id` in body — 404 regardless of payload

**Correct path:** `POST /tickets/{id}/comment` (singular, nested under ticket). Verified 2026-05-28.

```bash
# POST comment — response: {"comment": {...}}
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Resolution",
  "body": "Work summary here. Use <br> for line breaks. No <ul>/<li>.",
  "hidden": false,
  "do_not_email": false
}
JSON
)
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
```

- `hidden: true` = internal only (customer can't see)
- `do_not_email: true` = suppress email to customer
- Body is HTML; use `<br>` for line breaks. `<ul>`/`<li>` do not render in Syncro.
- **`\n` does NOT render as a line break in Syncro** — it shows as a space or is ignored. Every line break in the body MUST be a literal `<br>` tag. This applies whether the body was drafted by Ollama, Claude, or built from a shell variable. When using `jq --arg body "$VAR"`, a variable containing `\n` characters passes them as literal backslash-n, not HTML breaks. Write body strings with `<br>` inline, or use the heredoc form below to write proper HTML. Incident: ticket #32339 Birth Biologic 2026-05-28 — tech notes posted with `\n` separators, rendered as one unreadable block.
- Do NOT wrap the payload in `{"comment": {...}}` — returns 422.
- **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal.

#### Customer Assets (RMM) — read intelligence + write management

Assets are Syncro's RMM device records. Reads are free; writes follow the standard
preview+confirm gate. Response shape is `{"asset": {...}}` (single) / `{"assets": [...]}`
(list). RMM telemetry lives under `.asset.properties.kabuto_information`.

**Reads (verified with per-user token 2026-07-10):**

```bash
# List / search a customer's assets (100/page, .assets[])
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&per_page=100&api_key=${API_KEY}" | \
  tr -d '\000-\037' | jq -r '.assets[] | "\(.id)\t\(.asset_type_name)\t\(.name)"'

# Asset detail
curl -s "${BASE}/customer_assets/${ASSET_ID}?api_key=${API_KEY}" | tr -d '\000-\037' | \
  jq '{id:.asset.id, name:.asset.name, customer_id:.asset.customer_id, policy_folder_id:.asset.policy_folder_id, serial:.asset.asset_serial}'

# Windows patch posture -> {available_patches, available_patches_meta, installed_patches}
curl -s "${BASE}/customer_assets/${ASSET_ID}/patches?api_key=${API_KEY}" | tr -d '\000-\037' | \
  jq '{available: (.available_patches|length), installed: (.installed_patches|length)}'

# Installed software inventory (paginated) -> {installed_applications, meta}
# Use this to VERIFY an install (e.g. after /syncro deploy-agent) — see Agent Deployment.
curl -s "${BASE}/customer_assets/${ASSET_ID}/installed_applications?per_page=100&api_key=${API_KEY}" | \
  tr -d '\000-\037' | jq -r '.installed_applications[]?.name' | sort -u
```

`updated_at` is NOT a reliable "last online" (bulk account touches move it) — use the
ScreenConnect `GuestInfoUpdateTime` or kabuto data for real last-seen.

**Writes — `POST /customer_assets` (create), `PUT /customer_assets/{id}` (update).**
`name` is **required** on both (422 without it). Accepted body fields: `name`,
`asset_serial`, `asset_type_id`, `asset_type_name`, `customer_id`, `properties`. Preview the
full payload and confirm before writing; post a bot alert after. Response `{"asset": {...}}`.

```bash
# Update (rename / fix serial). Always resend name (required) even when only changing serial.
curl -s -X PUT "${BASE}/customer_assets/${ASSET_ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" --data-binary @- <<JSON
{"name": "${NEW_NAME}", "asset_serial": "${NEW_SERIAL}"}
JSON
```

**Move to a policy folder — `PUT /customer_assets/{id}` with `policy_folder_id` — WORKS, but
is TOKEN-SCOPE-GATED and MUST be capability-checked + verified.** Live-proven 2026-07-10 via
flip-and-restore on ACG-internal asset `12335235` (`692253 → 692278 → 692253`) with Howard's
full-scope token. The spec: *"Updating only `policy_folder_id` requires **Assets - Policy
Change**; with other fields requires **Assets - Edit** AND **Assets - Policy Change**. Nil,
nonexistent, or cross-customer folder IDs return 422."* `name` is required on the PUT, so a
move always sends other fields → needs BOTH scopes.

**The failure mode is SILENT, so never trust the 200.** A token WITHOUT Policy-Change returns
`HTTP 200` and simply does not change the folder (this is the symptom once misread as "RMM is
GUI-only"). `/me` reports coarse `asset:{write:true}` even without Policy-Change, so it cannot
detect this — you must probe and verify:

```bash
move_asset() {   # $1=asset_id  $2=target_folder_id   (needs $BASE $API_KEY)
  local AID="$1" TARGET="$2" NAME CUST CUR
  local A; A=$(curl -s "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" | tr -d '\000-\037')
  NAME=$(echo "$A" | jq -r '.asset.name'); CUST=$(echo "$A" | jq -r '.asset.customer_id')
  CUR=$(echo "$A" | jq -r '.asset.policy_folder_id')
  # 1) CAPABILITY PREFLIGHT — 200 = token can move; 401 = lacks Assets - Policy Change
  local PC; PC=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/policy_folders?customer_id=${CUST}&api_key=${API_KEY}")
  if [ "$PC" = "401" ]; then
    echo "[ERROR] This token lacks 'Assets - Policy Change' — cannot move policy folders."
    echo "        Options: move it in the Syncro web console, OR have a user whose token has"
    echo "        the scope run it (do NOT silently swap keys — writes attribute to the owner)."
    return 1
  fi
  # 2) PUT (name required), then 3) VERIFY the value actually changed — a 200 no-op = failure
  curl -s -X PUT "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" -H "Content-Type: application/json" \
    --data-binary "$(jq -nc --arg n "$NAME" --argjson pf "$TARGET" '{name:$n, policy_folder_id:$pf}')" >/dev/null
  local NEW; NEW=$(curl -s "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" | tr -d '\000-\037' | jq -r '.asset.policy_folder_id')
  if [ "$NEW" = "$TARGET" ]; then echo "[OK] asset ${AID} moved ${CUR} -> ${NEW}"; else
    echo "[ERROR] move NOT applied (still ${NEW}) — token likely under-scoped; do it in the console."; return 1; fi
}
```

**Policy-folder route quirks (verified 2026-07-10):** `GET /policy_folders` (bare) → **404**;
`GET /policy_folders?customer_id=N` → **200** (list a customer's folders — how you enumerate
folder IDs); `GET /policy_folders/{id}` → **401** even with full scope. Find valid same-customer
folder IDs from the `?customer_id=N` list or from other assets' `policy_folder_id` values.

**Fleet posture:** Howard's per-user token now has full scope (verified). Other per-user tokens
may not — the preflight above handles that per-run. Recommend granting every per-user Syncro
token the Policy-Change scope so `move-asset` behaves the same regardless of who runs it (Mike's
call — least-privilege alternative is to leave the preflight to degrade gracefully). Corrects
memory `reference-syncro-rmm-api-gui-only`; full detail in `.claude/standards/syncro/api-reference.md`.

**Archive (retire) is still GUI-only — no REST API.** Verified 2026-07-08: archiving is web
UI only (asset Details → **Actions → Archive**, or Assets table → **Bulk Actions →
Archive**). There is no archive endpoint and no `archived` field on the PUT. Do NOT substitute
`DELETE /customer_assets/{id}` — delete is destructive/irreversible, loses history, and breaks
ticket linkages (Archive keeps the asset on its tickets/alerts). To retire: hand the user the
asset list + the Bulk-Actions-→-Archive steps.

#### RMM Alerts

Live RMM alert feed (`GET /rmm_alerts` -> `{rmm_alerts, meta}`, verified 2026-07-10). Reads
free; `POST /rmm_alerts/{id}/mute` and `DELETE /rmm_alerts/{id}` (clear) are gated writes.

```bash
curl -s "${BASE}/rmm_alerts?per_page=50&api_key=${API_KEY}" | tr -d '\000-\037' | \
  jq -r '.rmm_alerts[] | "\(.id)\t\(.created_at)\t\(.description // .kind)"'
# Mute: POST /rmm_alerts/${ALERT_ID}/mute  |  Clear: DELETE /rmm_alerts/${ALERT_ID}
```

#### Agent Deployment (`/syncro deploy-agent`) — hybrid via GuruRMM

Push the Syncro RMM agent to a machine. Syncro's API has **no** installer-download endpoint
(verified: 0 of 116 paths), so this is a **hybrid**: the per-policy-folder installer URL is
harvested once from the Syncro GUI and **vaulted**; **GuruRMM (`/rmm`)** does the push (runs as
SYSTEM). `/rmm diagnose` already *detects* a Syncro agent — this is the deploy half.

**The installer URL is a per-client SECRET and is NOT constructible.** Decoded (2026-07-10):
`https://rmm.syncromsp.com/dl/msi/<b64>` where `<b64>` = base64 of
`v1-{customer_id}-{A}-48753-{policy_folder_id}`. `48753` is OUR account id (constant across all
clients); `customer_id` and `policy_folder_id` are API-readable — but **`A` is an unguessable
~1.8-billion per-client security token that appears nowhere in the API** (checked customer +
policy-folder objects). So you cannot build the URL; it must be harvested per client. Note the
link is an **MSI** (`/dl/msi/`) → silent install is `msiexec /qn`, not the `.exe --console` path.

**Prerequisites (one-time per client):**
1. In Syncro GUI: the client's Policy Folder → **Downloads** → copy the `.../dl/msi/<token>` URL.
2. Vault it (it embeds the secret `A`): `bash .claude/skills/vault/scripts/vault-helper.sh new
   clients/<slug>/syncro-agent-installer --kind generic --name "Syncro RMM Installer - <Client>"
   --set installer_url=<URL> --set customer_id=<N> --set policy_folder_id=<N>`. Already vaulted:
   `cascades-tucson`, `grabb-durando`, `reliant`, `howard-test`.

**Deploy workflow:**
1. Read the URL: `bash .claude/scripts/vault.sh get-field clients/<slug>/syncro-agent-installer
   credentials.installer_url`. If the client has no entry, STOP and have the URL harvested first.
2. Resolve the target host in GuruRMM (`/rmm` — hostname → UUID; confirm `os_type` windows +
   `is_connected`). Confirm the client↔host mapping with the user.
3. Preview the exact command + target; get explicit confirmation. **deploy-agent is
   outward-facing (installs software, may reboot) — always confirm first.**
4. Dispatch via GuruRMM (`command_type: powershell`, SYSTEM). Download the MSI and install silent:

   ```powershell
   $url = '<vaulted installer_url>'
   $msi = "$env:TEMP\SyncroSetup.msi"
   Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing
   Start-Process msiexec.exe -ArgumentList '/i',"`"$msi`"",'/qn','/norestart' -Wait
   ```
   (`.exe` variant, if a client's link is `/dl/exe/`: `Start-Process $exe -ArgumentList
   '--console','--allow-force-reboot' -Wait`.)
5. **Verify enrollment:** after ~2-3 min, confirm the new asset appears —
   `GET /customer_assets?customer_id=N&query=<host>` — and/or "Syncro" shows in the endpoint's
   `installed_applications`.
6. Bot alert (RMM write → `[RMM]`/`[DEPLOY]` routes to #dev-alerts).

**[VERIFY before first production use]** Confirm the `msiexec /qn /norestart` silent behavior on
RMM-TEST-MACHINE (Howard Test) before rolling to a customer. Full detail:
`.claude/standards/syncro/test-findings.md`.

#### Customers

```bash
# Search — returns id/name/email ONLY. Do NOT read prepay_hours from this list (it is null/stale here).
curl -s "${BASE}/customers?query=<name>&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]'
# Get one — this is the ONLY trustworthy source of prepay_hours. Always GET this before any billing preview.
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}'
```

#### Line Items

Normal billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry` for routine billing. Timers are an outlier — use one only when Mike explicitly requests a timer for a specific job (see `.claude/standards/syncro/time-entry-protocol.md`).

**Dead-end paths (all return 404 — do not probe):**
- `POST /ticket_line_items` — does not exist
- `POST /tickets/{id}/line_item` (singular) — does not exist
- `POST /tickets/{id}/line_items` (plural) — does not exist
- `PUT /tickets/{id}` with `line_items_attributes` in body — silently no-ops (returns ticket with empty line_items)

**Correct path:** `POST /tickets/{id}/add_line_item` using the **internal ticket ID** (e.g. 111387456, from `.ticket.id`), not the ticket number. Verified 2026-05-25 and 2026-05-28.

```bash
# Add line item — response is FLAT: {"id": N, "ticket_id": N, "product_id": N, "price_retail": N, ...}
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "product_id": 1190473,
  "name": "Labor - Remote Business",
  "description": "One-line billing description.",
  "quantity": 1.0,
  "price_retail": 150.00,
  "taxable": false
}
JSON
)
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')

# Update line item — response: {"ticket_line_item": {...}}
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_line_item_id": ${LINE_ID}, "price_retail": 175.00}
JSON

# Remove line item — response: {"success": true, "message": ""}
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_line_item_id": ${LINE_ID}}
JSON
```

**Required fields for add_line_item:**
- `name` — required (422 if missing); use the product name
- `description` — required (422 if missing); one-line billing narrative
- `product_id` — see labor product table below
- `quantity` — decimal hours for labor (0.5 = 30 min, 0.75 = 45 min, 1.0 = 60 min)
- `price_retail` — **must be set explicitly**; Syncro does NOT auto-populate from product rate via API
- `taxable` — **must be set explicitly**; always `false` for labor; `true` for taxable hardware

**Pre-flight every line item's category.** `GET /products/<id>` and confirm `.product.product_category` is non-null and is a real existing category (see the controlled-vocabulary rule above). A `null` category means the line will invoice with a blank CATEGORY column — STOP and flag the product instead of billing it. Never substitute an invented category.

**Do NOT remove line items after invoicing.** Leave them on the ticket.

**Labor product IDs** — always fetch `price_retail` live, never hardcode:

```bash
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
```

| product_id | Name | Use when |
|---|---|---|
| `1190473` | Labor - Remote Business | Remote work |
| `26118` | Labor - Onsite Business | Onsite work |
| `573881` | Labor - In Shop Business | Device brought to ACG shop |
| `26184` | Labor - Emergency or After Hours | ALL emergency. Non-prepaid: qty=actual hrs, set price_retail by channel (Onsite $262.50, Remote/In-Shop $225). Prepaid: qty=actual hrs×1.5 (rate moot) |
| `1049360` | Labor - Warranty work | Any warranty / no-charge work |
| `9269124` | Labor - Internal Labor | Internal ACG time, not customer-facing |
| `26117` | Fee - Travel Time | Per travel event |
| `68055` | Labor - Website Labor | Website work |
| `9269129` | Labor - Prepaid Project Labor | **DO NOT USE** — does not deduct prepay block |

**Emergency billing — branch on prepay_hours:**

| prepay_hours | Regular | Emergency |
|---|---|---|
| `0` / null | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours |
| `> 0` | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours × **1.5** |

Emergency = time-and-a-half, applied once. Non-prepaid: the `26184` rate ($262.50) carries the 1.5× in dollars, so qty = actual hours. Prepaid: invoice is $0 (debits by quantity), so the 1.5× goes in the **quantity** — bill `26184` at actual hours × 1.5 (e.g. 1.5 hrs → 2.25). (Updated 2026-05-27, Mike — previously "delivery-channel product ×1.5"; now `26184` so the line stays labeled emergency.)

#### Invoices

```bash
# Create invoice from ticket — response: {"invoice": {"id": N, "total": "N.N", "ticket_id": N, ...}}
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')

# Verify line items transferred (note: field is "price" not "price_retail" in invoice line_items)
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
  jq '{total: .invoice.total, lines: [.invoice.line_items[] | {item, name, quantity, price}]}'

# Delete invoice — response: {"message": "ID: We deleted # N."}
curl -s -X DELETE "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}"
```

POST `/invoices` pulls all current line items from the ticket into the invoice automatically. The POST response includes `.invoice.id` and `.invoice.total` — if either is null, GET `/invoices?customer_id=${CUST_ID}&per_page=5` and find the invoice by `ticket_id` match before taking any other action.

**Invoice Message (the on-screen "Invoice Message" text block) = the invoice `note` field.** Per-invoice, prints on the invoice, set with `PUT /invoices/{id}` body `{"note":"..."}` (response `{"invoice":{...}}`). Blank by default. Verified 2026-06-16 on the ACG internal test account (#67741 set/verify/restore).

**Auto invoice-note policy** — every invoice we generate gets a one-line note driven by the customer's prepaid block (`customer.prepay_hours`):

| Customer | Note set on the invoice |
|---|---|
| **No block** (`prepay_hours == 0`) | `Interested in discounted labor? Ask us about block-rate pricing.` |
| **Block, ≥ 4 hrs left** | `Block hours remaining: N.` |
| **Block, < 4 hrs left** | `Block hours remaining: N. You're running low — reply to renew...` **+ tags Winter in #bot-alerts** |

Reusable helper — call it once the invoice exists (needs `$BASE`, `$API_KEY`, `$REPO_ROOT`):

```bash
# set_invoice_note <invoice_id> <customer_id> [<pre_billing_prepay_hours>]
# Arg 3 matters for AD-HOC labor billing: pass the prepay fetched in billing Step 1 so a block
# JUST depleted to 0 still counts as a block customer (shows "remaining: 0 + renew", not the upsell).
# Omit arg 3 for recurring (recurring invoices don't debit the block → current balance is correct).
set_invoice_note() {
  local INV_ID="$1" CID="$2" PRE="${3:-}" CUST REMAIN NAME CHECK NOTE CUR R
  CUST=$(curl -s "${BASE}/customers/${CID}?api_key=${API_KEY}" | tr -d '\000-\037')
  REMAIN=$(echo "$CUST" | jq -r '.customer.prepay_hours // "0"')
  NAME=$(echo "$CUST" | jq -r '.customer.business_name // .customer.fullname // "customer"')
  CHECK="${PRE:-$REMAIN}"                                # block status source (pre-billing if given)
  R=$(awk "BEGIN{printf \"%g\",(${REMAIN:-0})+0}")        # tidy: 20.5, 3, 0
  if [ "$(awk "BEGIN{print ((${CHECK:-0})+0>0)?1:0}")" = "0" ]; then
    NOTE="Interested in discounted labor? Ask us about block-rate pricing."
  elif [ "$(awk "BEGIN{print ((${REMAIN:-0})+0<4)?1:0}")" = "1" ]; then
    NOTE="Block hours remaining: ${R}. You're running low — reply to renew your block and keep your discounted rate."
    bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
      "[SYNCRO] <@624666486362996755> LOW BLOCK — ${NAME} has ${R} prepaid hrs left (< 4) on invoice #${INV_ID} (cust ${CID}). Reach out about renewal."
  else
    NOTE="Block hours remaining: ${R}."
  fi
  CUR=$(curl -s "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}" | tr -d '\000-\037' | jq -r '.invoice.note // ""')
  if [ -n "$CUR" ] && [ "$CUR" != "null" ]; then echo "[note] skip ${INV_ID}: already has a note"; return 0; fi
  curl -s -X PUT "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}" -H "Content-Type: application/json" \
    --data-binary "$(jq -nc --arg n "$NOTE" '{note:$n}')" >/dev/null
  echo "[note] ${INV_ID} (${NAME}): ${NOTE}"
}
```

Winter's Discord id `624666486362996755` in the alert content (`<@...>`) pings her in #bot-alerts (post-bot-alert sets no `allowed_mentions`, so content mentions ping). **Never clobber a non-empty note** — a tech may have typed a real per-invoice message. One alert per low-block invoice.

#### Recurring Invoice Schedules

Recurring invoice templates are at `/schedules` — **not** `/recurring_invoices` (404). Generated invoices carry a `schedule_id` field linking back to the template. The `recurring_invoice_id` field on invoices is always null; ignore it.

**Hard rule: never run HTTP method probes against a live customer schedule. Always use an ACG internal test schedule (customer_id 15353550) for destructive-method testing. `DELETE /schedules/{id}` has no confirmation and destroys the template immediately — past generated invoices are unaffected but future billing stops.** Any test schedule (or ticket/estimate/appointment) created must have its subject/name prefixed with `[TEST]` — see Hard Rules above.

Valid `frequency` values (verified): `Monthly`, `Quarterly`, `Annually`, `Weekly`, `Biweekly`. All other strings return `{"error": ["Frequency must be a valid selection"]}`.

```bash
# List all schedules — 50/page, filterable
curl -s "${BASE}/schedules?api_key=${API_KEY}"
curl -s "${BASE}/schedules?customer_id=${CUST_ID}&api_key=${API_KEY}"
curl -s "${BASE}/schedules?paused=true&api_key=${API_KEY}"
# meta: {total_pages, total_entries, per_page}

# Get one schedule (includes full lines array)
curl -s "${BASE}/schedules/${SCHED_ID}?api_key=${API_KEY}" | jq '
{
  id: .schedule.id,
  name: .schedule.name,
  customer_id: .schedule.customer_id,
  frequency: .schedule.frequency,
  next_run: .schedule.next_run,
  paused: .schedule.paused,
  subtotal: .schedule.subtotal,
  email_customer: .schedule.email_customer,
  lines: [.schedule.lines[] | {id, name, quantity, price_retail, product_id}]
}'

# Create schedule — response: {"schedule": {...}}
# Required: customer_id, name, frequency, next_run
SCHED_RESP=$(curl -s -X POST "${BASE}/schedules?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "customer_id": CUST_ID,
  "name": "Schedule name",
  "frequency": "Monthly",
  "next_run": "YYYY-MM-DD",
  "email_customer": true,
  "paused": false,
  "snail_mail": false,
  "charge_mop": false,
  "invoice_unbilled_ticket_charges": false
}
JSON
)
SCHED_ID=$(echo "$SCHED_RESP" | jq -r '.schedule.id')

# Update schedule — any writable field, response: {"schedule": {...}}
# Updatable: name, frequency, next_run, paused, email_customer, snail_mail,
#            charge_mop, invoice_unbilled_ticket_charges
curl -s -X PUT "${BASE}/schedules/${SCHED_ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"next_run": "YYYY-MM-DD", "paused": false}
JSON

# Delete schedule — IMMEDIATE, no confirmation, response: {"success": "deleted"}
# ONLY run this against ACG internal test schedules unless explicitly directed by user
curl -s -X DELETE "${BASE}/schedules/${SCHED_ID}?api_key=${API_KEY}"

# Add line item to schedule — response: {"schedule_line_item": {...}}
# Required: name, description, product_id, quantity, price_retail
# Note: taxable not required but include it; defaults unclear
LI_RESP=$(curl -s -X POST "${BASE}/schedules/${SCHED_ID}/line_items?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "product_id": ${PRODUCT_ID},
  "name": "Product name",
  "description": "Line item description",
  "quantity": "1.0",
  "price_retail": ${RATE},
  "taxable": false
}
JSON
)
LI_ID=$(echo "$LI_RESP" | jq -r '.schedule_line_item.id')

# Update schedule line item — response: {"schedule_line_item": {...}}
# Updatable: name, description, quantity, price_retail, taxable
curl -s -X PUT "${BASE}/schedules/${SCHED_ID}/line_items/${LI_ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"quantity": "3.0", "price_retail": ${NEW_RATE}, "name": "Product name", "description": "Updated description"}
JSON

# Delete schedule line item — response: {"success": true}
curl -s -X DELETE "${BASE}/schedules/${SCHED_ID}/line_items/${LI_ID}?api_key=${API_KEY}"
```

**GET single line item (`GET /schedules/{id}/line_items/{li_id}`) returns 404.** Read line items via `GET /schedules/{id}` → `.schedule.lines[]`.

**Schedule object fields:** `id`, `account_id`, `customer_id`, `name`, `frequency`, `next_run`, `paused`, `subtotal`, `email_customer`, `snail_mail`, `charge_mop`, `invoice_unbilled_ticket_charges`, `last_invoice_paid`, `lines[]`

**Schedule line item fields:** `id`, `schedule_id`, `product_id`, `product_category`, `name`, `description`, `quantity`, `price_retail` / `retail_cents`, `price_cost` / `cost_cents`, `taxable`, `position`, `one_time_charge`, `device_ids`, `recurring_type_id` (Pax8 subscription UUID link), `asset_type_id`, `user_id`, plus Syncro Backup-specific fields (`syncro_backup_billing_type`, `syncro_backup_credit_type`, `syncro_backup_bill_all`, `syncro_backup_bill_after_licenses`, `syncro_backup_bill_after_storage`), `vendor_name`, `vendor_product_name`, `bill_all_units`, `bill_units_threshold`, `include_zero_charge`

**Pax8 link field:** `recurring_type_id` on a schedule line item holds the Pax8 subscription UUID. Pax8 sets this when syncing subscriptions and uses it to identify which line to PUT when a subscription changes quantity or price.

#### Recurring invoice note sweep (block hours / low-block alerts on auto-generated invoices)

Recurring invoices are generated by Syncro **automatically** — we don't run code at generation time — so to put the same invoice-note policy (block hours remaining / low-block renew + Winter tag / non-block upsell) on them, run a **sweep** after the recurring run that scans recently-generated recurring invoices and applies `set_invoice_note` (the helper in the Invoices section). Recurring invoices carry a non-null `schedule_id`; they do **not** debit the block, so the current `prepay_hours` is the right remaining figure (omit the pre-billing arg).

```bash
# Sweep recurring invoices from the last N days and set their block-hours note.
SINCE=$(date -d '8 days ago' +%Y-%m-%d 2>/dev/null || date -v-8d +%Y-%m-%d)
PAGE=1
while :; do
  BATCH=$(curl -s "${BASE}/invoices?per_page=100&page=${PAGE}&api_key=${API_KEY}" | tr -d '\000-\037')
  ROWS=$(echo "$BATCH" | jq -r --arg since "$SINCE" '.invoices[] | select(.schedule_id != null) | select(.date >= $since) | "\(.id) \(.customer_id)"')
  [ -z "$ROWS" ] && break
  echo "$ROWS" | while read -r INV CID; do set_invoice_note "$INV" "$CID"; done   # no pre-billing arg
  CNT=$(echo "$BATCH" | jq -r '.invoices | length'); [ "${CNT:-0}" -lt 100 ] && break
  PAGE=$((PAGE+1))
done
```

Idempotent (skips invoices that already have a note). Run it on demand after monthly billing, or schedule it (a cron/scheduled agent the day after each recurring run). Low-block customers still get the renewal line + a Winter ping, once per invoice.

#### Estimates

Estimates (quotes) always require a linked ticket — create the ticket first, then the estimate with `ticket_id` set. This enables private notes (purchase links, sourcing details) on the ticket using the standard hidden comment endpoint. Estimates created without a ticket have no notes surface accessible via API. Verified 2026-05-22 against ACG internal account.

**Full estimate workflow (ticket → estimate → line items → recalc → private notes):**

```bash
TODAY=$(date +%Y-%m-%d)

# 1. Create ticket
TICKET_RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "customer_id": ${CUST_ID},
  "subject": "Estimate - <subject>",
  "status": "New",
  "problem_type": "Estimate"
}
JSON
)
TICKET_ID=$(echo "$TICKET_RESP" | jq -r '.ticket.id')

# 2. Create estimate linked to ticket
EST_RESP=$(curl -s -X POST "${BASE}/estimates?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "customer_id": ${CUST_ID},
  "name": "<subject>",
  "date": "${TODAY}",
  "ticket_id": ${TICKET_ID}
}
JSON
)
ESTIMATE_ID=$(echo "$EST_RESP" | jq -r '.estimate.id')
ESTIMATE_NUM=$(echo "$EST_RESP" | jq -r '.estimate.number')

# 3. Add line items
# Labor — price_retail is respected on POST
LI_LABOR=$(curl -s -X POST "${BASE}/estimates/${ESTIMATE_ID}/line_items?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "product_id": ${LABOR_PRODUCT_ID},
  "name": "<labor product name>",
  "description": "<work description>",
  "quantity": ${HOURS},
  "price_retail": ${RATE},
  "taxable": false
}
JSON
)

# Hardware — price_retail is IGNORED on POST (product 32252 always lands at $0); set via PUT below
LI_HW=$(curl -s -X POST "${BASE}/estimates/${ESTIMATE_ID}/line_items?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "product_id": 32252,
  "name": "<item name>",
  "description": "<brief description>",
  "quantity": ${QTY},
  "price_retail": ${PRICE},
  "taxable": true
}
JSON
)
LI_HW_ID=$(echo "$LI_HW" | jq -r '.line_item.id')

# 4. Set hardware price via PUT (required — POST price is ignored for product 32252)
curl -s -X PUT "${BASE}/estimates/${ESTIMATE_ID}/line_items/${LI_HW_ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"price": ${PRICE}, "price_retail": ${PRICE}}
JSON
> /dev/null

# 5. Force recalc — PUT any field on the estimate; response contains live totals
RECALC=$(curl -s -X PUT "${BASE}/estimates/${ESTIMATE_ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"date": "${TODAY}"}
JSON
)
echo "Estimate #${ESTIMATE_NUM} — subtotal: $(echo "$RECALC" | jq '.estimate.subtotal')  total: $(echo "$RECALC" | jq '.estimate.total')"

# 6. Add private notes to linked ticket — one per hardware line item with purchase source/link
# First note: include the estimate link; one additional note per item as needed
curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "subject": "Estimate Links",
  "body": "Estimate #${ESTIMATE_NUM}: https://computerguru.syncromsp.com/estimates/${ESTIMATE_ID}<br><br>Purchase: <item name> x${QTY} - <URL> - MSRP \$X.XX ea, quoted at \$${PRICE} (markup)",
  "hidden": true,
  "do_not_email": true
}
JSON
> /dev/null

# 7. Verify completion — task is NOT done until all checks pass
# Check estimate line items
VERIFY_EST=$(curl -s "${BASE}/estimates/${ESTIMATE_ID}?api_key=${API_KEY}")
echo "Line items on estimate:"
echo "$VERIFY_EST" | jq '[.estimate.line_items[]? | {name, quantity, price}]'

# Check ticket private notes
VERIFY_TKT=$(curl -s "${BASE}/tickets/${TICKET_ID}?api_key=${API_KEY}")
echo "Private notes on ticket:"
echo "$VERIFY_TKT" | jq '[.ticket.comments[]? | select(.hidden == true) | {subject, created_at}]'

# SUCCESS CRITERIA — all must be true before reporting done:
# [1] estimate line_items count matches number of items user requested
# [2] ticket has at least one hidden comment per line item (or one combined note covering all)
# [3] estimate subtotal/total match expected values
# [4] ticket subject starts with "Estimate - " (fix with PUT /tickets/{id} if not)
# [5] bot alert posted
# If any check fails, add the missing note or fix the missing item before completing.
```

**Required fields for POST /estimates:** `customer_id`, `date` (ISO date string `"YYYY-MM-DD"`)
**Optional:** `name` (estimate title), `ticket_id` (link to ticket), `location_id`
**Statuses:** `Fresh` (default), `Approved`, `Declined`

**Line item field naming in estimate responses:** `item` = product name, `name` = description, `price` = unit rate. This matches invoice line_items, NOT ticket `add_line_item` (which uses `price_retail`).

**GET /estimates line_items vs POST response:** GET returns `line_items` as an array on `.estimate.line_items[]`. POST `/line_items` returns the line item under `.line_item` (singular, not nested under estimate).

**Stale estimate totals after line item PUT — force recalc with a PUT touch:** After using `PUT /estimates/{id}/line_items/{id}` to set hardware prices, the estimate-level `total` and `subtotal` remain stale until the estimate record itself is saved. Fix: `PUT /estimates/{id}` with any innocuous field (e.g. `{"date": "YYYY-MM-DD"}`). The PUT response and all subsequent GETs show correct recalculated totals. No dedicated `/recalculate` endpoint exists (404). Verified 2026-05-22 on test estimate #7184.

**Private notes on estimates — use the linked ticket:** Estimates have no notes/comments API surface of their own (`POST /estimates/{id}/notes` and `/comments` both 404; `note`/`private_note`/`notes` fields on PUT are silently ignored). The ticket linked via `ticket_id` is the only place to store private notes. Use `POST /tickets/{id}/comment` with `hidden: true, do_not_email: true`. Note the endpoint is `/comment` (singular) — `/comments` (plural) returns 404. Verified 2026-05-22 on test ticket #32315.

**Hardware on estimates:** All hardware uses `product_id: 32252` ("Hardware", base price $0). Set the actual price per item via `name` and `price_retail` fields — but price_retail is ignored on POST for this product. Always follow with a PUT to set the price. Never look up individual hardware product IDs.

```bash
# GET estimate (verify line items and totals)
curl -s "${BASE}/estimates/${ESTIMATE_ID}?api_key=${API_KEY}" | \
  jq '{id: .estimate.id, number: .estimate.number, subtotal: .estimate.subtotal, total: .estimate.total,
       lines: [.estimate.line_items[]? | {id, item, name, quantity, price}]}'

# DELETE estimate
curl -s -X DELETE "${BASE}/estimates/${ESTIMATE_ID}?api_key=${API_KEY}"
# Response: {"message": "N: We deleted # NNNN. "}
```

### Display formatting

When showing ticket lists, format as:

```
#32164  New        Jerry Burger           Own cloud thing again
#32163  New        LeeAnn Parkinson       Remote - Jim cant access his email
#32162  Invoiced   Len's Auto Brokerage   Server upgrade
```

When showing ticket detail, include:
- Ticket number, subject, status, priority
- Customer name + contact
- Created date, due date, last updated
- Assigned tech
- Comments (most recent first, truncated to last 5)
- Line items / billing status

### Billing workflow

**Step 1 — Gather (ask user once, run GETs in parallel)**

Ask: minutes to bill + labor type (remote / onsite / emergency / in-shop / warranty). For emergency, also ask for delivery channel (Remote / Onsite / In-Shop) if not stated — it determines `price_retail` and cannot be assumed. Then fetch:

```bash
TICKET=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}")
CUST_ID=$(echo "$TICKET" | jq -r '.ticket.customer_id')
CUST=$(curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}")
PREPAY=$(echo "$CUST" | jq -r '.customer.prepay_hours // "0.0"')
# Determine product_id from labor type + prepay, then:
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
QTY=$(echo "scale=4; ${MINUTES}/60" | bc)
TOTAL=$(echo "scale=2; ${RATE}*${QTY}" | bc)
```

**Step 2 — Draft + confirm**

Use Ollama (or draft directly) for comment body and line item description. Show preview:
```
Ticket:      #NNNNN — <subject>
Customer:    <name>  [prepay: X hrs remaining / none]
Labor:       <product name>  <qty> hrs @ $<rate>  = $<total>
Comment:     <body>
Description: <line item description>
```
Wait for explicit confirmation before any write.

**Step 3 — Execute (in order, no branching)**

```bash
# 1. Post comment — response: {"comment": {...}}
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"subject": "Resolution", "body": "<body>", "hidden": false, "do_not_email": false}
JSON
)
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
# STOP if null: GET ticket, check .ticket.comments[] by subject

# 2. Add line item — response is FLAT: {"id": N, ...}
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{
  "product_id": ${PRODUCT_ID},
  "name": "<product name>",
  "description": "<description>",
  "quantity": ${QTY},
  "price_retail": ${RATE},
  "taxable": false
}
JSON
)
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')
# STOP if null: GET ticket, check .ticket.line_items[]

# 3. Create invoice — response: {"invoice": {"id": N, "total": "N.N", ...}}
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
INVOICE_TOTAL=$(echo "$INV_RESP" | jq -r '.invoice.total')
# If INVOICE_ID is null: GET /invoices?customer_id=${CUST_ID}&per_page=5, find by ticket_id

# 3b. Invoice-note policy: non-block -> upsell hint; block -> hours remaining; block <4hr -> renew + tag Winter.
#     Pass the PRE-billing prepay ($PREPAY from Step 1) so a just-depleted block still counts as block.
set_invoice_note "$INVOICE_ID" "$CUST_ID" "$PREPAY"   # helper defined in the Invoices section

# 4. Mark Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"status": "Invoiced"}
JSON

# 5. Bot alert
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
  "[SYNCRO] Mike billed #<number> (<customer>) — ${QTY}h <labor_type>, \$${INVOICE_TOTAL} → https://computerguru.syncromsp.com/tickets/${ID}"
```

**Prepaid invoice total will be $0.00 — this is correct.** The line item is annotated "- Applied X Prepay Hours." Confirm the block decremented by re-fetching `customer.prepay_hours`.

**Heredoc quoting:** heredocs that interpolate shell variables (`${ID}`, `${CUST_ID}`, etc.) use unquoted `<<JSON`. Static-payload heredocs use `<<'JSON'` (single-quoted, suppresses `$` expansion). Pick the right form per heredoc.

`--labor` → product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `in-shop` → 573881, `warranty` → 1049360, `internal` → 9269124, `travel` → 26117, `website` → 68055

### Error handling

- 401: API key invalid or expired
- 404: ticket/customer/invoice not found
- 422: validation error (show the error message from response body)
- 429: rate limited (wait 60s and retry)

### Integration with session logs

When closing a ticket (`/syncro close`), offer to create a session log entry in `clients/<customer>/session-logs/` documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.

### Post to #bot-alerts (after every write) — MANDATORY

Post after every successful Syncro write. Never post before the write completes. Never post for read-only operations (list, view, search).

**Invocation:**

```bash
ALERT_OUT=$(bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" "<message>")
echo "$ALERT_OUT"
```

**Success:** script prints `[OK] post-bot-alert: posted to #bot-alerts (message_id=N)` and exits 0. Done.

**Failure:** script prints `[WARNING] post-bot-alert: <reason>` and exits 0. The script always exits 0 — check the output text, not the exit code. On a warning:
- Surface the warning text to the user ("bot-alert failed: <reason>")
- Do NOT retry
- Do NOT redo any Syncro writes
- The Syncro work is complete; the missed alert is informational only

**Message format:** one line — `[SYNCRO] <Tech> <verb> #<number> (<customer>) - <summary> -> <link>`

- `<Tech>`: `mike` -> Mike, `howard` -> Howard (matches `identity.json` user)
- Use ticket **number** (`#32164`) in the text; build the link from the numeric **id**
- One alert per workflow — billing touches ticket + invoice, send one alert linked to the ticket
- **ASCII only** — use `-` not `—` and `->` not `→`. Unicode characters corrupt in Windows Git Bash and cause Discord to reject the JSON body (400 invalid JSON).

**Links:**

| Entity | URL |
|---|---|
| Ticket (create / update / close / comment / bill) | `https://computerguru.syncromsp.com/tickets/<ticket.id>` |
| Customer (create) | `https://computerguru.syncromsp.com/customers/<customer.id>` |

**Examples:**

```bash
# Ticket created
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
  "[SYNCRO] Howard created #32301 (Desert Auto Tech) - Server won't boot -> https://computerguru.syncromsp.com/tickets/110736645"
# Success output: [OK] post-bot-alert: posted to #bot-alerts (message_id=1507055781780918404)

# Billed + invoiced
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
  "[SYNCRO] Mike billed #32164 (Jerry Burger) - 1.0h remote, \$150.00 -> https://computerguru.syncromsp.com/tickets/110169036"

# Prepaid billing
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
  "[SYNCRO] Mike billed #32203 (Desert Auto Tech) - 1.5h onsite, applied 1.5 prepay hrs, \$0.00 -> https://computerguru.syncromsp.com/tickets/109895882"

# Ticket status updated
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
  "[SYNCRO] Mike resolved #32271 (Peaceful Spirit Massage) - IKEv2 VPN setup complete -> https://computerguru.syncromsp.com/tickets/110169036"
```

The script uses `jq` to build the JSON payload, so quotes and `$` in the message are safe. The script has a 15-second curl timeout and soft-fails on token missing, network error, or non-200 Discord response.
