# /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 ``` ## 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//add_line_item` with the correct `product_id`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`. **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/` 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/` → `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 `price_retail` MUST be set explicitly:** Earlier guidance to "omit `price_retail` and let Syncro auto-calc from the product rate" was wrong — the rate does NOT populate automatically. Fetch it with `GET /products/` → `.product.price_retail` and pass it on `add_line_item`. Omitting it leaves the line at $0.00 and the invoice posts at $0.00 (verified 2026-04-23 on #32203). **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. **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. ## 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). ### 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-.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' 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 #: 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 `26118`, qty must be actual_hours × 1.5 5. `comment_body` uses `
      `, not `
        /
      • ` 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-.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** | Look up from `GET /customers/{id}` → `.contacts[]`; show list, ask user to pick | | 9 | **Address/Site** | `address_id` — also comes from customer contacts with address data | | 10 | **Appointment Type** | From table above; omit section if no appointment needed | | 11 | **Location** | Free text; usually blank unless onsite at non-primary address | | 12 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment | | 13 | **End Time** | Default: start + 90 minutes | | 14 | **Appointment Owner** | Usually same as assigned tech; noted for calendar attribution (not a separate API field — inherits from ticket `user_id`) | | 15 | **Do Not Invite** | If not onsite, suppress calendar invite — note: not directly controllable via API; inform user if they need this set manually | | 16 | **Asset** | Search `GET /customer_assets?customer_id=N&query=` if a specific device is involved | #### Step 2 — Look up customer data Before showing the preview, fetch what you need: ```bash # Get contacts and addresses curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{contacts: [.customer.contacts[] | {id, name, address1, email}]}' # Search assets curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&query=&api_key=${API_KEY}" | jq '[.assets[] | {id, name, asset_type}]' ``` #### Step 3 — 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: Address:
        Do Not Email: APPOINTMENT ----------- Type: Start: End: (90 min) Location: ASSET: Confirm? (yes/no) ``` #### Step 4 — 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", "contact_id": N, "address_id": N, "start_at": "ISO8601", "end_at": "ISO8601", "asset_ids": [N] } JSON ) TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id') CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id') ``` Omit null/blank fields from the payload before piping. The `'JSON'` quoting on the heredoc opener is required — it suppresses bash variable and backtick expansion inside, which matters when descriptions contain `$` (passwords, prices, regex, etc.). **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, "appointment_type_id": N, "start_at": "ISO8601", "end_at": "ISO8601", "location": "" } JSON ``` 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"}` | — | **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. **`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
        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. - 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 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 @- < 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 @- < 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 @- < () — ${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 "$CLAUDETOOLS_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 **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 "$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.