NightDesk now supports Syncro and classifies every call by issue type
Two things happened overnight in NightDesk that are worth writing about separately, but they're connected by the same underlying question: "What did NightDesk actually do last night?"
Syncro is now PSA #4
NightDesk started with ConnectWise because that's what AGJ runs. HaloPSA and Autotask came next — they cover the majority of the US MSP market. But Syncro is a real PSA with a real user base, particularly among smaller shops that grew up on it and never had a reason to migrate.
The SyncroClient follows the same contract as the other three:
class SyncroClient:
def create_triage_ticket(...) -> TicketSummary
def create_callback_ticket(...) -> TicketSummary
def add_note(ticket_id, text, internal=True) -> None
def get_contact_by_phone(phone_e164) -> dict | None
def get_recent_tickets(company_identifier, limit=5) -> list[dict]
Syncro's API has a couple quirks. The Authorization header takes the bare API key — no Bearer prefix. And Syncro doesn't have the concept of named queues or boards, so the triage context goes into the ticket body rather than routing metadata. Callback tickets use "Waiting on Customer" as the status, which maps cleanly to the morning queue intent.
The tenant config is one JSON blob in Secrets Manager at nightdesk/{tenant_id}/syncro-credentials. If that secret exists, the router uses Syncro. If it doesn't, it falls through to Autotask, HaloPSA, and ConnectWise in that order.
NightDesk now covers the four PSAs that, together, account for something like 80% of US MSP installs.
Every call now gets an issue type
This one started as a bug.
handler.py had this block that I wrote weeks ago and forgot about:
try:
from issue_tagger import tag_issue
tag = tag_issue([{"speaker": t.speaker, "text": t.text} for t in transcript])
issue_category = tag.get("category", "other")
issue_subcategory = tag.get("subcategory", "")
except Exception:
log.exception("tag_issue failed (non-fatal)")
The try/except was swallowing a ModuleNotFoundError on every call. issue_tagger.py existed in my working tree but had never been committed. Every call was quietly landing as category "other."
The tagger has two modes. When ANTHROPIC_API_KEY is set, it calls Claude Haiku with a structured prompt and gets back a JSON object with category, subcategory, and confidence. When the API key isn't set, it falls back to a keyword-match stub that covers the most common terms:
_NETWORK_WORDS = frozenset({"internet", "wifi", "vpn", "network", "dns", ...})
_ACCESS_WORDS = frozenset({"password", "reset", "locked", "mfa", "login", ...})
# etc.
The stub isn't great but it's good enough to distinguish "VPN is down" from "printer offline" without any API cost. For a system that might handle 20-30 calls a night, Haiku at a fraction of a cent per call is the right call for production anyway.
The morning debrief now shows the breakdown
The Slack debrief was already posting overnight call summaries. After adding the category data, I added one more line:
By type: 3 network · 2 access · 1 hardware
It only appears when at least one call has been tagged. Calls from before the tagger shipped don't get a retroactive label, so the line silently disappears for those records — no "0 other · 0 other · 0 other" noise.
The category sort is by count descending. Most common issue type first. If you're running a shop with a lot of VPN calls, "network" is probably always first and that tells you something.
The weekly email digest
This one is for the MSPs who don't use Slack. The Slack debrief is great for team channels, but plenty of smaller shops run on email. Adding a weekly_digest_email field to the tenant record now triggers a Monday morning HTML email with:
- Total calls for the week, by action (resolved / escalated / callback)
- Calls by night of week — so you can see if Tuesday nights are heavier than Wednesday nights
- Category breakdown (same network/hardware/access data as the debrief)
- Escalated ticket IDs
- Average conversation length (turns, not minutes)
The per-night distribution is the piece I think MSPs will actually find useful. You can staff Thursday on-call differently than Monday on-call if you know Thursday is historically the busiest night. That data is in the call_stats table — nobody was reading it until now.
What the test count actually means
930 tests across the NightDesk agent. I track this not because it's a marketing number but because it's the only thing that keeps me honest about which features actually work vs which features I think work.
The issue_tagger tests were 24 tests for code that was functionally broken. They all passed because the code was internally correct — the tagger works fine in isolation. But the integration test was the bug: the module was never in the deployed bundle.
The lesson I keep relearning: a green test suite means the module works, not that it's wired up. Integration tests that call the real handler with all the real dependencies are the ones that catch this. I have those for the core flow. I added them for the tagger.
The Syncro pilot is open for any MSP on Syncro who wants to run NightDesk. Same 90-day free pilot as everyone else. The install is one secret in Secrets Manager, one runbook JSON file in S3, and one Twilio webhook URL.
Drop me a note if you want to try it.