# /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
```

## 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/

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

**Billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry`.** The timer workflow is not used. 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/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/<id>` and read `prepay_hours`. If `prepay_hours > 0`, the customer has a prepaid block — bill `26118` (Onsite) at `quantity × 1.5` instead (prepaid debits by quantity, not by dollars). Never stack `26118` + `26184` for the same hours — the Emergency product rate already has the 1.5× multiplier baked in. Verified 2026-04-23 on ticket #32203 (Desert Auto Tech) after Winter caught the bug.

**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.

**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
py -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.

## 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).

### 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.

| identity.json user | Syncro user | user_id |
|---|---|---|
| `mike` | Michael Swanson | 1735 |
| `howard` | Howard Enos | 1750 |

Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.

### Get API key

```bash
BASE="https://computerguru.syncromsp.com/api/v1"

# Per-user keys — actions in Syncro are attributed to the key owner
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
  mike)   API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
  howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
  *)      echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
esac
```

### 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** — run once at the start of any write operation, reuse `$OLLAMA` for the rest of the session:

```bash
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.92.127.64:11434/api/tags >/dev/null 2>&1; then
    OLLAMA="http://100.92.127.64:11434"
else
    OLLAMA=""  # fallback: Claude drafts directly
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 $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc.
PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt"
mkdir -p "$(dirname "$PROMPT_FILE")"
cat > "$PROMPT_FILE" <<'ENDPROMPT'
<prompt content here>
ENDPROMPT

if [ -n "$OLLAMA" ]; then
    DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -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 `26118`, qty must be actual_hours × 1.5
5. `comment_body` uses `<br>`, not `<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.`

---

### 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. "}` | — |

**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

```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.
- 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.

#### Customers

```bash
# Search
curl -s "${BASE}/customers?query=<name>&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]'
# Get one
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}'
```

#### Line Items

All billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry`.

```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

**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 | Non-prepaid emergency only; 1.5× rate baked in |
| `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 | delivery-channel product, qty = actual_hours × **1.5** |

Prepaid blocks debit by quantity not dollars — for emergency prepaid, use the normal delivery-channel product at 1.5× qty, not `26184` (which has 1.5× already in the dollar rate).

#### 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.

#### 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). 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

# 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 "$CLAUDETOOLS_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 "$CLAUDETOOLS_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 "$CLAUDETOOLS_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 "$CLAUDETOOLS_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 "$CLAUDETOOLS_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 "$CLAUDETOOLS_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.
