Per Winter: leave appointment type blank unless user names one of the known types. If omitting, include delivery method in ticket subject for calendar visibility. Applies to both the gather-inputs table and the appointment POST. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
35 KiB
/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
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, 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/<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 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/<id> → .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=<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
descriptionfield (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:
price_retailwas fetched live fromGET /products/<product_id>→.product.price_retailand matches what will be shown to the userquantity= minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)- Computed total =
price_retail × quantity— matches what was communicated to user - If labor_type is
emergencyandprepay_hours > 0: product must be26118, qty must be actual_hours × 1.5 comment_bodyuses<br>, not<ul>/<li>- 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
- User logs into Syncro → Admin → API Tokens → New (
/api_tokens/new) - 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)
- Copy the token once (Syncro only shows it on creation)
- 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") - 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:
GET /appointments?start_at=<date>— find appointment ID- Confirm new date/time with user
PUT /appointments/<id>with{"start_at": "ISO8601", "end_at": "ISO8601"}- Verify response:
.appointment.start_atmatches 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: <start_at>
End: <end_at> (90 min)
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"} |
— |
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
# 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_IDis 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 namedescription— required (422 if missing); one-line billing narrativeproduct_id— see labor product table belowquantity— decimal hours for labor (0.5 = 30 min, 0.75 = 45 min, 1.0 = 60 min)price_retail— must be set explicitly; Syncro does NOT auto-populate from product rate via APItaxable— must be set explicitly; alwaysfalsefor labor;truefor 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.
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 (matchesidentity.jsonuser)- 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/<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.