Files
claudetools/.claude/commands/syncro.md
Mike Swanson e9dac6580c Add ticket subject prefix check to estimate success criteria
The linked ticket subject must start with "Estimate - " before an estimate
task is considered complete. Added as criterion [4] in both the hard rules
block and the workflow success criteria block, with a self-correction step
(PUT /tickets/{id}) if the check fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:27:47 -07:00

44 KiB
Raw Blame History

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

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

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:

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:

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

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:

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:

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):

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

# 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

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

# 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_retailmust be set explicitly; Syncro does NOT auto-populate from product rate via API
  • taxablemust 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:

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

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

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.

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

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)

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

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: ")
  • 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:

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