# /syncro — Syncro PSA ticket management Create, update, close, comment on, and bill tickets in Syncro PSA. ## Usage ``` /syncro Show open tickets summary /syncro ticket View ticket details + comments /syncro create Create new ticket /syncro update Update ticket status /syncro close Close/resolve a ticket /syncro comment Add a comment to a ticket /syncro bill Add billable time and create invoice /syncro search Search tickets by subject/customer /syncro customers Search customers /syncro move-appointment Find and reschedule an existing appointment /syncro estimate Create ticket + linked estimate with line items and private purchase notes /syncro schedules List recurring invoice schedules for a customer /syncro schedule View a schedule's template and line items ``` ## 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) **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//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/` 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.) **Prepaid customers — ALL billing (not just emergency):** `GET /customers/` → `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/` 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. **`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. ## Implementation When invoked, use the Syncro REST API via `curl`. All requests include `?api_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 | |---|---|---| | `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-.sops.yaml`. ### Get API key ```bash BASE="https://computerguru.syncromsp.com/api/v1" # Get repo root from identity.json (set during machine onboarding) # Fallback to dynamic detection for legacy machines that haven't updated identity.json yet IDENTITY_PATH="${HOME}/.claude/identity.json" if [ ! -f "$IDENTITY_PATH" ]; then # Try in-repo identity.json (gitignored, machine-specific) REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) if [ -n "$REPO_ROOT" ]; then IDENTITY_PATH="$REPO_ROOT/.claude/identity.json" fi fi if [ ! -f "$IDENTITY_PATH" ]; then echo "[ERROR] Cannot locate identity.json - run onboarding first" >&2 exit 1 fi REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH") if [ -z "$REPO_ROOT" ]; then # Legacy fallback for machines without claudetools_root in identity.json REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) if [ -z "$REPO_ROOT" ]; then echo "[ERROR] claudetools_root not set in identity.json and not in a git directory" >&2 echo "[ERROR] Add 'claudetools_root' field to $IDENTITY_PATH" >&2 exit 1 fi echo "[WARNING] Using git-detected repo root. Add 'claudetools_root' to identity.json to avoid this." >&2 fi # Per-user keys — actions in Syncro are attributed to the key owner USER_ID=$(jq -r '.user // empty' "$IDENTITY_PATH") 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** — 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' 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 #: CUSTOMER: TECH: WORK DONE: LABOR: min ( hrs) @ $/hr = $ Rules: - comment_body must use
for line breaks. Do NOT use
    or
  • — 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": " line breaks>", "line_item_description": "", "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 #: CUSTOMER: NOTE: VISIBILITY: <"Internal only" | "Customer-visible"> Rules: - Use
    for line breaks. Do NOT use
      or
    • . - Professional and factual. No filler. Return ONLY valid JSON: { "subject": "Update", "body": " line breaks>", "preview": "" } ``` #### 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.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 `
      ` — scan the string for `\n` (literal backslash-n) or bare newlines and replace with `
      `. No `
        `/`
      • `. 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-.sops.yaml <) subdomain: computerguru api-base-url: https://computerguru.syncromsp.com/api/v1 api-docs: https://api-docs.syncromsp.com/ status: active owner: syncro_user_id: tags: [msp-tools, per-user] credentials: credential: notes: Per-user Syncro API token for . 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-.sops.yaml") ``` 5. Commit + push vault repo. ### Endpoints reference #### Tickets | Operation | Method | Endpoint | Body | |---|---|---|---| | List tickets | GET | `/tickets?status=&per_page=25` | — | | Get ticket | GET | `/tickets/` | — | | Create ticket | POST | `/tickets` | see full create workflow below | | Update ticket | PUT | `/tickets/` | `{"status": "In Progress", "priority": "..."}` | | Delete ticket | DELETE | `/tickets/` | — | **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/` | Returns `{"appointment": {...}}` | | Create | POST | `/appointments` | Used in ticket creation flow (Call 3) | | Move / edit | PUT | `/appointments/` | Verified 2026-04-24 — updates `start_at`/`end_at` | | Delete | DELETE | `/appointments/` | Not yet verified | **Finding an appointment by customer:** `GET /appointments?start_at=` 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=` — find appointment ID 2. Confirm new date/time with user 3. `PUT /appointments/` 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=` 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: Subject: Issue Type: Priority: Description: Due Date: Assigned To: Contact: (or named contact if specified) Do Not Email: APPOINTMENT (omit section if no appointment) ----------- Type: Start: at ← day name verified with py datetime End: at (90 min default) Location: ASSET: 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": "", "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/.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}` | — | **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
        for line breaks. No
          /
        • .", "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 `
          ` for line breaks. `
            `/`
          • ` 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 `
            ` 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 `
            ` 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. #### Customers ```bash # Search curl -s "${BASE}/customers?query=&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 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 @- < 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 @- < [] # 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 @- </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 @- <", "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 @- <", "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 @- <", "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 @- <", "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 @- < /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 @- <
            Purchase: x${QTY} - - 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 — Customer: [prepay: X hrs remaining / none] Labor: hrs @ $ = $ Comment: 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": "", "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 @- <", "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 @- < 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 # () — ${QTY}h , \$${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 `</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" "") 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: ` 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: ") - 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] # () - -> ` - ``: `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/` | | Customer (create) | `https://computerguru.syncromsp.com/customers/` | **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.