- Billing now uses add_line_item directly; timer_entry/charge_timer_entry removed - Added Verified Response Shapes table for all endpoints (tested live against ACG internal customer) - Billing workflow rewritten as strict 5-step locked script with no branches - Added STOP rule: never try alternative endpoints/formats on unexpected responses - bot-alerts section: explicit success ([OK] + message_id) and failure ([WARNING]) criteria - Updated feedback memory to supersede the old timer-first rule Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
756 lines
34 KiB
Markdown
756 lines
34 KiB
Markdown
# /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
|
||
|
||
```bash
|
||
BASE="https://computerguru.syncromsp.com/api/v1"
|
||
|
||
# Per-user keys — actions in Syncro are attributed to the key owner
|
||
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
|
||
case "$USER_ID" in
|
||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
||
*) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
|
||
esac
|
||
```
|
||
|
||
### Ollama drafting
|
||
|
||
Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes.
|
||
|
||
**Availability check** — run once at the start of any write operation, reuse `$OLLAMA` for the rest of the session:
|
||
|
||
```bash
|
||
if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
|
||
OLLAMA="http://localhost:11434"
|
||
elif curl -s -m 3 http://100.92.127.64:11434/api/tags >/dev/null 2>&1; then
|
||
OLLAMA="http://100.92.127.64:11434"
|
||
else
|
||
OLLAMA="" # fallback: Claude drafts directly
|
||
fi
|
||
```
|
||
|
||
**Draft call:**
|
||
|
||
```bash
|
||
# Write prompt to a workspace path both the Write tool and Git Bash agree on
|
||
# (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in
|
||
# Write vs Git Bash). Use $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc.
|
||
PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt"
|
||
mkdir -p "$(dirname "$PROMPT_FILE")"
|
||
cat > "$PROMPT_FILE" <<'ENDPROMPT'
|
||
<prompt content here>
|
||
ENDPROMPT
|
||
|
||
if [ -n "$OLLAMA" ]; then
|
||
DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -c "
|
||
import os, urllib.request, json, sys
|
||
prompt = open(os.environ['PROMPT_FILE']).read()
|
||
body = json.dumps({
|
||
'model': 'qwen3:14b',
|
||
'messages': [{'role': 'user', 'content': prompt}],
|
||
'stream': False,
|
||
'think': False
|
||
}).encode()
|
||
res = json.loads(urllib.request.urlopen(
|
||
urllib.request.Request('$OLLAMA/api/chat', body), timeout=60
|
||
).read())
|
||
print(res['message']['content'])
|
||
")
|
||
else
|
||
echo "[INFO] Ollama unavailable — Claude will draft directly."
|
||
DRAFT=""
|
||
fi
|
||
```
|
||
|
||
**When to use Ollama:**
|
||
- Comment body drafting (`/syncro comment`, `/syncro close`, billing resolution notes)
|
||
- Billing `description` field (line item billing narrative)
|
||
- Ticket initial description during `/syncro create`
|
||
|
||
**When NOT to use Ollama:**
|
||
- JSON field selection (product_id, quantity, price_retail) — Claude owns this; always fetch price_retail live from Syncro
|
||
- Read operations (GET)
|
||
- Auth, credential, or security decisions
|
||
|
||
#### Billing draft prompt template
|
||
|
||
```
|
||
You are a Syncro PSA billing assistant. Draft a resolution comment and billing description.
|
||
|
||
TICKET #<id>: <subject>
|
||
CUSTOMER: <customer_name>
|
||
TECH: <tech_name>
|
||
WORK DONE: <user description of work>
|
||
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>
|
||
|
||
Rules:
|
||
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — they do not render.
|
||
- Keep it professional and factual. No filler phrases.
|
||
- line_item_description is one plain-text line, billing-facing.
|
||
|
||
Return ONLY valid JSON, no prose before or after:
|
||
{
|
||
"comment_subject": "Resolution",
|
||
"comment_body": "<HTML with <br> line breaks>",
|
||
"line_item_description": "<one line plain text>",
|
||
"preview": "<2-3 sentence plain-text summary for tech review>"
|
||
}
|
||
```
|
||
|
||
#### Comment draft prompt template
|
||
|
||
```
|
||
You are a Syncro PSA tech assistant. Draft a ticket comment.
|
||
|
||
TICKET #<id>: <subject>
|
||
CUSTOMER: <customer_name>
|
||
NOTE: <user's note or description>
|
||
VISIBILITY: <"Internal only" | "Customer-visible">
|
||
|
||
Rules:
|
||
- Use <br> for line breaks. Do NOT use <ul> or <li>.
|
||
- Professional and factual. No filler.
|
||
|
||
Return ONLY valid JSON:
|
||
{
|
||
"subject": "Update",
|
||
"body": "<HTML with <br> line breaks>",
|
||
"preview": "<plain text for tech review>"
|
||
}
|
||
```
|
||
|
||
#### Claude review checklist (always run before presenting to user)
|
||
|
||
Whether the draft came from Ollama or Claude wrote it directly:
|
||
|
||
1. `price_retail` was fetched live from `GET /products/<product_id>` → `.product.price_retail` and matches what will be shown to the user
|
||
2. `quantity` = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)
|
||
3. Computed total = `price_retail × quantity` — matches what was communicated to user
|
||
4. If labor_type is `emergency` and `prepay_hours > 0`: product must be `26118`, qty must be actual_hours × 1.5
|
||
5. `comment_body` uses `<br>`, not `<ul>/<li>`
|
||
6. No internal notes or credential data in a customer-visible comment body
|
||
|
||
If a check fails: correct it and note the fix in the preview so the user can see what changed.
|
||
|
||
#### Fallback behavior
|
||
|
||
If `OLLAMA` is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce `[INFO] Ollama unavailable — drafting directly.`
|
||
|
||
---
|
||
|
||
### Adding a per-user key
|
||
|
||
1. User logs into Syncro → Admin → API Tokens → New (`/api_tokens/new`)
|
||
2. Type: Integration API Token (or Custom with all standard scopes: asset/customer/ticket/invoice/payment read+write+delete, worksheet add+manage+delete, chat + script.execute)
|
||
3. Copy the token once (Syncro only shows it on creation)
|
||
4. Encrypt to vault:
|
||
```bash
|
||
cat > $VAULT_ROOT/msp-tools/syncro-<user>.sops.yaml <<YAML
|
||
kind: api-key
|
||
name: Syncro (<Full Name>)
|
||
subdomain: computerguru
|
||
api-base-url: https://computerguru.syncromsp.com/api/v1
|
||
api-docs: https://api-docs.syncromsp.com/
|
||
status: active
|
||
owner: <user>
|
||
syncro_user_id: <id>
|
||
tags: [msp-tools, per-user]
|
||
credentials:
|
||
credential: <TOKEN>
|
||
notes: Per-user Syncro API token for <Full Name>. Created YYYY-MM-DD.
|
||
YAML
|
||
# MUST run from vault root so sops picks up .sops.yaml
|
||
(cd "$VAULT_ROOT" && sops --encrypt --in-place "msp-tools/syncro-<user>.sops.yaml")
|
||
```
|
||
5. Commit + push vault repo.
|
||
|
||
### Endpoints reference
|
||
|
||
#### Tickets
|
||
|
||
| Operation | Method | Endpoint | Body |
|
||
|---|---|---|---|
|
||
| List tickets | GET | `/tickets?status=<status>&per_page=25` | — |
|
||
| Get ticket | GET | `/tickets/<id>` | — |
|
||
| Create ticket | POST | `/tickets` | see full create workflow below |
|
||
| Update ticket | PUT | `/tickets/<id>` | `{"status": "In Progress", "priority": "..."}` |
|
||
| Delete ticket | DELETE | `/tickets/<id>` | — |
|
||
|
||
**Ticket statuses:** `New`, `In Progress`, `Waiting on Customer`, `Waiting on Vendor`, `Scheduled`, `Resolved`, `Invoiced`, `Closed`
|
||
|
||
**Priority format** (number-prefixed string): `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`
|
||
Default: `"2 Normal"`. Use `"4 Urgent"` for emergency/after-hours.
|
||
|
||
**Problem types (Issue Type dropdown — use closest match, else "Not determined"):**
|
||
`API`, `Email`, `Emergency Service`, `File Services / Permissions`, `Hardware`, `Maintenance`,
|
||
`New User / M365 Account Creation`, `New User / Workstation Deployment`, `Not determined`,
|
||
`Onsite`, `Other`, `Phone/VOIP`, `Remote`, `Security`, `Server Migration`, `Service Request`,
|
||
`Software`, `Website`
|
||
|
||
**Appointment types:**
|
||
|
||
| Name | ID | location_type |
|
||
|---|---|---|
|
||
| In Shop | 4321 | shop |
|
||
| Onsite | 4322 | customer |
|
||
| Phone Call | 4323 | pre_defined |
|
||
| Reminder | 193053 | manual_entry |
|
||
| Remote | 59289 | pre_defined |
|
||
|
||
**Tech user IDs:** Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760
|
||
|
||
#### Appointments
|
||
|
||
| Operation | Method | Endpoint | Notes |
|
||
|---|---|---|---|
|
||
| List (today) | GET | `/appointments?start_at=YYYY-MM-DD` | Filter by date; use `.summary` to match customer |
|
||
| Get | GET | `/appointments/<id>` | Returns `{"appointment": {...}}` |
|
||
| Create | POST | `/appointments` | Used in ticket creation flow (Call 3) |
|
||
| Move / edit | PUT | `/appointments/<id>` | Verified 2026-04-24 — updates `start_at`/`end_at` |
|
||
| Delete | DELETE | `/appointments/<id>` | Not yet verified |
|
||
|
||
**Finding an appointment by customer:** `GET /appointments?start_at=<date>` returns all appointments — filter client-side with `select(.summary | test("customer name"; "i"))` or `select(.ticket.customer_id == N)`. The `customer_id` query param does not filter correctly.
|
||
|
||
**Move workflow:**
|
||
1. `GET /appointments?start_at=<date>` — find appointment ID
|
||
2. Confirm new date/time with user
|
||
3. `PUT /appointments/<id>` with `{"start_at": "ISO8601", "end_at": "ISO8601"}`
|
||
4. Verify response: `.appointment.start_at` matches intended time
|
||
|
||
**Response shape:** `{"appointment": {...}}` — parse as `.appointment.id`, `.appointment.start_at`, etc.
|
||
|
||
---
|
||
|
||
### Ticket creation workflow (full — 3 API calls)
|
||
|
||
Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.
|
||
|
||
#### Step 1 — Gather inputs
|
||
|
||
Collect in one pass (do not ask field by field):
|
||
|
||
| # | Field | Notes |
|
||
|---|---|---|
|
||
| 1 | **Subject** | Brief title: reason for the ticket |
|
||
| 2 | **Issue Type** (`problem_type`) | From dropdown above; "Not determined" if unclear |
|
||
| 3 | **Priority** | "2 Normal" default; "4 Urgent" for emergencies |
|
||
| 4 | **Description** | Expanded detail — becomes the "Initial Issue" comment body |
|
||
| 5 | **Do Not Email** | Suppress customer notification on ticket create? (yes for internal/reminder tickets) |
|
||
| 6 | **Due Date** | ISO date |
|
||
| 7 | **Assigned Tech** | Who owns the ticket. Defaults to API key owner if not specified (mike → 1735, howard → 1750). MUST always be included in the POST payload — never omit. |
|
||
| 8 | **Contact** | 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=<name>` 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=<name>&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: <name>
|
||
Subject: <subject>
|
||
Issue Type: <problem_type>
|
||
Priority: <priority>
|
||
Description: <description>
|
||
Due Date: <due_date>
|
||
Assigned To: <tech name>
|
||
Contact: <contact name>
|
||
Address: <address>
|
||
Do Not Email: <yes/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 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": "<the full description>",
|
||
"hidden": false,
|
||
"do_not_email": true
|
||
}
|
||
JSON
|
||
# Parse: .comment.id (NOT .id — see Hard Rules)
|
||
```
|
||
|
||
Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.
|
||
|
||
**Call 3 — Create appointment (only if start_at provided):**
|
||
|
||
```bash
|
||
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<'JSON'
|
||
{
|
||
"ticket_id": N,
|
||
"customer_id": N,
|
||
"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/<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
|
||
|
||
```bash
|
||
# POST comment — response: {"comment": {...}}
|
||
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<'JSON'
|
||
{
|
||
"subject": "Resolution",
|
||
"body": "Work summary here. Use <br> for line breaks. No <ul>/<li>.",
|
||
"hidden": false,
|
||
"do_not_email": false
|
||
}
|
||
JSON
|
||
)
|
||
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
|
||
```
|
||
|
||
- `hidden: true` = internal only (customer can't see)
|
||
- `do_not_email: true` = suppress email to customer
|
||
- Body is HTML; use `<br>` for line breaks. `<ul>`/`<li>` do not render in Syncro.
|
||
- Do NOT wrap the payload in `{"comment": {...}}` — returns 422.
|
||
- **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal.
|
||
|
||
#### Customers
|
||
|
||
```bash
|
||
# Search
|
||
curl -s "${BASE}/customers?query=<name>&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]'
|
||
# Get one
|
||
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}'
|
||
```
|
||
|
||
#### Line Items
|
||
|
||
All billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry`.
|
||
|
||
```bash
|
||
# Add line item — response is FLAT: {"id": N, "ticket_id": N, "product_id": N, "price_retail": N, ...}
|
||
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<'JSON'
|
||
{
|
||
"product_id": 1190473,
|
||
"name": "Labor - Remote Business",
|
||
"description": "One-line billing description.",
|
||
"quantity": 1.0,
|
||
"price_retail": 150.00,
|
||
"taxable": false
|
||
}
|
||
JSON
|
||
)
|
||
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')
|
||
|
||
# Update line item — response: {"ticket_line_item": {...}}
|
||
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<JSON
|
||
{"ticket_line_item_id": ${LINE_ID}, "price_retail": 175.00}
|
||
JSON
|
||
|
||
# Remove line item — response: {"success": true, "message": ""}
|
||
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<JSON
|
||
{"ticket_line_item_id": ${LINE_ID}}
|
||
JSON
|
||
```
|
||
|
||
**Required fields for add_line_item:**
|
||
- `name` — required (422 if missing); use the product name
|
||
- `description` — required (422 if missing); one-line billing narrative
|
||
- `product_id` — see labor product table below
|
||
- `quantity` — decimal hours for labor (0.5 = 30 min, 0.75 = 45 min, 1.0 = 60 min)
|
||
- `price_retail` — **must be set explicitly**; Syncro does NOT auto-populate from product rate via API
|
||
- `taxable` — **must be set explicitly**; always `false` for labor; `true` for taxable hardware
|
||
|
||
**Do NOT remove line items after invoicing.** Leave them on the ticket.
|
||
|
||
**Labor product IDs** — always fetch `price_retail` live, never hardcode:
|
||
|
||
```bash
|
||
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
|
||
```
|
||
|
||
| product_id | Name | Use when |
|
||
|---|---|---|
|
||
| `1190473` | Labor - Remote Business | Remote work |
|
||
| `26118` | Labor - Onsite Business | Onsite work |
|
||
| `573881` | Labor - In Shop Business | Device brought to ACG shop |
|
||
| `26184` | Labor - Emergency or After Hours | Non-prepaid emergency only; 1.5× rate baked in |
|
||
| `1049360` | Labor - Warranty work | Any warranty / no-charge work |
|
||
| `9269124` | Labor - Internal Labor | Internal ACG time, not customer-facing |
|
||
| `26117` | Fee - Travel Time | Per travel event |
|
||
| `68055` | Labor - Website Labor | Website work |
|
||
| `9269129` | Labor - Prepaid Project Labor | **DO NOT USE** — does not deduct prepay block |
|
||
|
||
**Emergency billing — branch on prepay_hours:**
|
||
|
||
| prepay_hours | Regular | Emergency |
|
||
|---|---|---|
|
||
| `0` / null | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours |
|
||
| `> 0` | delivery-channel product, qty = actual_hours | delivery-channel product, qty = actual_hours × **1.5** |
|
||
|
||
Prepaid blocks debit by quantity not dollars — for emergency prepaid, use the normal delivery-channel product at 1.5× qty, not `26184` (which has 1.5× already in the dollar rate).
|
||
|
||
#### Invoices
|
||
|
||
```bash
|
||
# Create invoice from ticket — response: {"invoice": {"id": N, "total": "N.N", "ticket_id": N, ...}}
|
||
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<JSON
|
||
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
|
||
JSON
|
||
)
|
||
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
|
||
|
||
# Verify line items transferred (note: field is "price" not "price_retail" in invoice line_items)
|
||
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
|
||
jq '{total: .invoice.total, lines: [.invoice.line_items[] | {item, name, quantity, price}]}'
|
||
|
||
# Delete invoice — response: {"message": "ID: We deleted # N."}
|
||
curl -s -X DELETE "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}"
|
||
```
|
||
|
||
POST `/invoices` pulls all current line items from the ticket into the invoice automatically. The POST response includes `.invoice.id` and `.invoice.total` — if either is null, GET `/invoices?customer_id=${CUST_ID}&per_page=5` and find the invoice by `ticket_id` match before taking any other action.
|
||
|
||
### Display formatting
|
||
|
||
When showing ticket lists, format as:
|
||
|
||
```
|
||
#32164 New Jerry Burger Own cloud thing again
|
||
#32163 New LeeAnn Parkinson Remote - Jim cant access his email
|
||
#32162 Invoiced Len's Auto Brokerage Server upgrade
|
||
```
|
||
|
||
When showing ticket detail, include:
|
||
- Ticket number, subject, status, priority
|
||
- Customer name + contact
|
||
- Created date, due date, last updated
|
||
- Assigned tech
|
||
- Comments (most recent first, truncated to last 5)
|
||
- Line items / billing status
|
||
|
||
### Billing workflow
|
||
|
||
**Step 1 — Gather (ask user once, run GETs in parallel)**
|
||
|
||
Ask: minutes to bill + labor type (remote / onsite / emergency / in-shop / warranty). Then fetch:
|
||
|
||
```bash
|
||
TICKET=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}")
|
||
CUST_ID=$(echo "$TICKET" | jq -r '.ticket.customer_id')
|
||
CUST=$(curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}")
|
||
PREPAY=$(echo "$CUST" | jq -r '.customer.prepay_hours // "0.0"')
|
||
# Determine product_id from labor type + prepay, then:
|
||
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
|
||
QTY=$(echo "scale=4; ${MINUTES}/60" | bc)
|
||
TOTAL=$(echo "scale=2; ${RATE}*${QTY}" | bc)
|
||
```
|
||
|
||
**Step 2 — Draft + confirm**
|
||
|
||
Use Ollama (or draft directly) for comment body and line item description. Show preview:
|
||
```
|
||
Ticket: #NNNNN — <subject>
|
||
Customer: <name> [prepay: X hrs remaining / none]
|
||
Labor: <product name> <qty> hrs @ $<rate> = $<total>
|
||
Comment: <body>
|
||
Description: <line item description>
|
||
```
|
||
Wait for explicit confirmation before any write.
|
||
|
||
**Step 3 — Execute (in order, no branching)**
|
||
|
||
```bash
|
||
# 1. Post comment — response: {"comment": {...}}
|
||
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<'JSON'
|
||
{"subject": "Resolution", "body": "<body>", "hidden": false, "do_not_email": false}
|
||
JSON
|
||
)
|
||
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
|
||
# STOP if null: GET ticket, check .ticket.comments[] by subject
|
||
|
||
# 2. Add line item — response is FLAT: {"id": N, ...}
|
||
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<JSON
|
||
{
|
||
"product_id": ${PRODUCT_ID},
|
||
"name": "<product name>",
|
||
"description": "<description>",
|
||
"quantity": ${QTY},
|
||
"price_retail": ${RATE},
|
||
"taxable": false
|
||
}
|
||
JSON
|
||
)
|
||
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')
|
||
# STOP if null: GET ticket, check .ticket.line_items[]
|
||
|
||
# 3. Create invoice — response: {"invoice": {"id": N, "total": "N.N", ...}}
|
||
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<JSON
|
||
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
|
||
JSON
|
||
)
|
||
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
|
||
INVOICE_TOTAL=$(echo "$INV_RESP" | jq -r '.invoice.total')
|
||
# If INVOICE_ID is null: GET /invoices?customer_id=${CUST_ID}&per_page=5, find by ticket_id
|
||
|
||
# 4. Mark Invoiced
|
||
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
|
||
-H "Content-Type: application/json" \
|
||
--data-binary @- <<'JSON'
|
||
{"status": "Invoiced"}
|
||
JSON
|
||
|
||
# 5. Bot alert
|
||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
|
||
"[SYNCRO] Mike billed #<number> (<customer>) — ${QTY}h <labor_type>, \$${INVOICE_TOTAL} → https://computerguru.syncromsp.com/tickets/${ID}"
|
||
```
|
||
|
||
**Prepaid invoice total will be $0.00 — this is correct.** The line item is annotated "- Applied X Prepay Hours." Confirm the block decremented by re-fetching `customer.prepay_hours`.
|
||
|
||
**Heredoc quoting:** heredocs that interpolate shell variables (`${ID}`, `${CUST_ID}`, etc.) use unquoted `<<JSON`. Static-payload heredocs use `<<'JSON'` (single-quoted, suppresses `$` expansion). Pick the right form per heredoc.
|
||
|
||
`--labor` → product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `in-shop` → 573881, `warranty` → 1049360, `internal` → 9269124, `travel` → 26117, `website` → 68055
|
||
|
||
### Error handling
|
||
|
||
- 401: API key invalid or expired
|
||
- 404: ticket/customer/invoice not found
|
||
- 422: validation error (show the error message from response body)
|
||
- 429: rate limited (wait 60s and retry)
|
||
|
||
### Integration with session logs
|
||
|
||
When closing a ticket (`/syncro close`), offer to create a session log entry in `clients/<customer>/session-logs/` documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.
|
||
|
||
### Post to #bot-alerts (after every write) — MANDATORY
|
||
|
||
Post after every successful Syncro write. Never post before the write completes. Never post for read-only operations (list, view, search).
|
||
|
||
**Invocation:**
|
||
|
||
```bash
|
||
ALERT_OUT=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" "<message>")
|
||
echo "$ALERT_OUT"
|
||
```
|
||
|
||
**Success:** script prints `[OK] post-bot-alert: posted to #bot-alerts (message_id=N)` and exits 0. Done.
|
||
|
||
**Failure:** script prints `[WARNING] post-bot-alert: <reason>` and exits 0. The script always exits 0 — check the output text, not the exit code. On a warning:
|
||
- Surface the warning text to the user ("bot-alert failed: <reason>")
|
||
- Do NOT retry
|
||
- Do NOT redo any Syncro writes
|
||
- The Syncro work is complete; the missed alert is informational only
|
||
|
||
**Message format:** one line — `[SYNCRO] <Tech> <verb> #<number> (<customer>) — <summary> → <link>`
|
||
|
||
- `<Tech>`: `mike` → Mike, `howard` → Howard (matches `identity.json` user)
|
||
- Use ticket **number** (`#32164`) in the text; build the link from the numeric **id**
|
||
- One alert per workflow — billing touches ticket + invoice, send one alert linked to the ticket
|
||
|
||
**Links:**
|
||
|
||
| Entity | URL |
|
||
|---|---|
|
||
| Ticket (create / update / close / comment / bill) | `https://computerguru.syncromsp.com/tickets/<ticket.id>` |
|
||
| Customer (create) | `https://computerguru.syncromsp.com/customers/<customer.id>` |
|
||
|
||
**Examples:**
|
||
|
||
```bash
|
||
# Ticket created
|
||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
|
||
"[SYNCRO] Howard created #32301 (Desert Auto Tech) — Server won't boot → https://computerguru.syncromsp.com/tickets/110736645"
|
||
# Success output: [OK] post-bot-alert: posted to #bot-alerts (message_id=1507055781780918404)
|
||
|
||
# Billed + invoiced
|
||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
|
||
"[SYNCRO] Mike billed #32164 (Jerry Burger) — 1.0h remote, \$150.00 → https://computerguru.syncromsp.com/tickets/110169036"
|
||
|
||
# Prepaid billing
|
||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
|
||
"[SYNCRO] Mike billed #32203 (Desert Auto Tech) — 1.5h onsite, applied 1.5 prepay hrs, \$0.00 → https://computerguru.syncromsp.com/tickets/109895882"
|
||
|
||
# Ticket status updated
|
||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
|
||
"[SYNCRO] Mike resolved #32271 (Peaceful Spirit Massage) — IKEv2 VPN setup complete → https://computerguru.syncromsp.com/tickets/110169036"
|
||
```
|
||
|
||
The script uses `jq` to build the JSON payload, so quotes and `$` in the message are safe. The script has a 15-second curl timeout and soft-fails on token missing, network error, or non-200 Discord response.
|