Skip to content

Repository files navigation

HaloCLI

Standalone HaloPSA CLI for safe operator and automation workflows.

HaloCLI is intentionally independent of Bifrost. It uses direct HaloPSA OAuth client-credentials auth by default and emits JSON so humans and scripts can use the same command surface.

Install

From the latest GitHub release tag:

pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0

If you use uv:

uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0

From a local checkout:

pipx install --force .

If you use uv:

uv tool install .

For development:

python -m pip install -e ".[dev]"

Supported Python versions are 3.12 – 3.14 (requires-python = ">=3.12"). Python 3.10 reaches end-of-life on 2026-10-31 and 3.11 is security-only until 2027-10, so both were dropped rather than tested into retirement — install 0.8.2 if you are pinned to either. The Windows MSI takes no Python at all: it bundles its own interpreter (Python 3.14 as of 0.9.0), which is why the MSI build's Python version is a security-relevant choice rather than a build detail.

Release packaging is documented in RELEASE.md. GitHub Releases include the wheel, source distribution, and a CycloneDX SBOM.

Configure

Environment variables win over stored profile values:

$env:HALO_TENANT_URL = "https://yourtenant.halopsa.com"
$env:HALO_CLIENT_ID = "..."
$env:HALO_CLIENT_SECRET = "..."
$env:HALO_SCOPE = "all"

Or create a local profile:

halocli configure --auth-mode client-credentials

Do not commit profile files or secrets.

Entra SSO And Interactive Login

HaloCLI can safely discover whether a Halo instance exposes CLI-usable authorization-code style endpoints:

halocli auth discover --tenant-url https://yourtenant.halopsa.com

If discovery does not confirm an authorization endpoint, keep using client-credentials for API automation:

halocli configure --profile thomas --auth-mode client-credentials

You can create an experimental interactive profile, but halocli auth login will refuse to continue until discovery has confirmed the instance supports the right browser callback flow. The easiest onboarding sequence is:

halocli configure `
  --profile thomas `
  --tenant-url https://yourtenant.halopsa.com `
  --client-id YOUR_HALO_OAUTH_CLIENT_ID `
  --auth-mode halo-interactive

halocli auth discover `
  --tenant-url https://yourtenant.halopsa.com `
  --profile thomas `
  --save

halocli auth login --profile thomas
halocli auth test --profile thomas

Profile defaulting: when exactly one profile is configured, --profile may be omitted — default resolves to it (and to its token cache). With multiple profiles, an explicit --profile is required and the error lists the configured names.

Interactive login opens the system browser, listens on a temporary localhost callback, exchanges the authorization code at Halo's token endpoint, and stores tokens in the operating system's secure credential store. On Windows this is Windows Credential Manager, backed by Windows data protection behavior. On macOS this is Keychain. Use --allow-file-token-cache only on machines where secure credential storage is unavailable and you understand the local-file tradeoff.

The default redirect URL to register in Halo is:

http://127.0.0.1:8765/callback

If that port conflicts on a workstation, use halocli auth login --callback-port 8766 and add the matching redirect URL in the Halo application.

For macOS fleets managed by Intune, treat Intune as the install and config distribution path first. Entra SSO is a Halo user-login path first. If Halo does not expose delegated API tokens for CLI use, managed-device identity will need a separate Entra-backed broker rather than pretending the Intune enrollment is itself a Halo API credential.

Diagnosing 403s

Halo gates API access on two independent layers, both of which must pass:

  1. the API application's Permissions tab (OAuth scopes — these are fixed at token issuance, so a refresh never widens them; re-run halocli auth login after changing them);
  2. the logging-in agent's role and its module access levels.

Scopes only ever narrow further — they never grant beyond the agent's role. Because of that, being a full admin in the Halo UI does not imply an API call will succeed: the application may simply lack the scope for that endpoint.

auth whoami reports the identity the token acts as and the scope Halo actually granted (read from the local token cache, not the profile's requested scope), and --check probes endpoints read-only:

halocli auth whoami --profile thomas
halocli auth whoami --profile thomas --check /Invoice --check /Tickets

A 403 response includes a diagnostic pointing at this command. --check performs a GET with take=1 — it never issues a write.

Examples

halocli auth test
halocli auth whoami                          # identity + granted OAuth scope
halocli auth whoami --check /Invoice         # is this endpoint reachable now?
halocli auth discover --tenant-url https://yourtenant.halopsa.com
halocli tickets list --open --max-records 25
halocli tickets list --all                   # every record (no ceiling)
halocli clients list --param search=Example
halocli sites get 123
halocli assets list --param client_id=42 --output table
halocli agents list --output table
halocli raw GET /Client --param search=Example

Resource Commands

HaloCLI has registry-driven read commands for common HaloPSA resources:

tickets, clients, agents, teams, users, kb, sites, assets, actions,
statuses, priorities, categories, ticket-types, slas, appointments,
contracts, invoices, invoice-payments, invoice-statuses,
recurring-invoices, opportunities, projects, suppliers, items,
quotations, releases, reports, timesheet-events, canned-text, searches,
outgoing, outgoing-attempts, email-templates, tags, popup-notes,
lookups, outcomes, call-log, mailboxes, charge-rates, address,
agent-check-ins, approval-process, approval-process-rules, asset-groups,
asset-types, automations, billing-templates, booking-types,
budget-types, cabs, call-scripts, client-prepays, consignments,
cost-centres, currencies, custom-buttons, custom-queries, custom-tables,
dashboard-links, database-lookups, distribution-lists,
email-address-books, email-rules, email-stores, events, event-rules,
faq-lists, feeds, feedbacks, fields, field-groups, field-infos,
holidays, incoming-webhook-attempts, invoice-changes, item-groups,
item-stocks, item-stock-histories, journeys, licence-changes,
notifications, notification-messages, organisations, pdf-templates,
products, purchase-orders, qualifications, release-types, roles,
sales-mailboxes, sales-mailbox-details, sales-orders, schedules,
schedule-occurrences, services, service-categories,
service-request-details, service-restrictions, stock-bins, stock-traces,
taxes, templates, ticket-approvals, ticket-areas, ticket-rules,
ticket-type-fields, to-do-groups, user-changes, user-roles,
view-columns, view-filters, view-list-groups, view-lists, workflows,
workflow-targets, formattedemails, workflowsteps, webhooks, workdays,
software-licences, crm-notes, top-levels, expenses, timesheets,
attachments, area-request-types, audits, bulk-emails, cab-members,
cab-roles, crm-note-replies, csp-consumption-data, csv-templates,
call-events, certificates, change-calendars, confirm-closures,
contact-groups, contact-group-contacts, contract-rules,
contract-schedules, contract-schedule-plans, device-licences,
distribution-list-logs, downtimes, email-template-variables,
historical-ticket-volumes, invoice-detail-prorata, mail-campaign-logs,
meter-readings, escalation-messages, powershell-scripts,
powershell-script-criteria, powershell-script-processing,
product-branches, product-components, publish-profiles, recurring-items,
release-note-groups, release-pipelines, remote-sessions,
report-repositories, resource-types, saved-forecasts,
service-availabilities, service-statuses, single-sign-on-attempts,
software-licence-roles, supplier-contracts, tax-rules,
ticket-type-groups, timeslots, to-dos, transcription-stores,
xtype-roles, csp-invoices, item-suppliers, asset-changes,
asset-software, incoming-emails

54 of these are route-verified reads whose tenant holds no rows yet: their list returns whatever Halo returns (usually count: 0 — every documented and scope parameter was probed live on 2026-10-02) and the table view shows id only until a populated tenant yields column evidence. JSON output always carries Halo's raw payload.

Each resource supports:

halocli <resource> list --param key=value --max-records 25
halocli <resource> get ID

Undocumented params are warned about, not silently dropped: Halo ignores unknown query params (--param assigned_to=37 on tickets returns the entire tenant), so list warns per param against the spec and suggests close matches when they exist (--param clinet_id=1 → "did you mean client_id?"). /Tickets alone documents 194 params — the warning exists because a filter that silently does nothing is worse than an error.

list stops at 500 records by default and says so: HaloPSA tenants can be large (/Tickets on our own instance holds 137k records — an unbounded fetch would take ~18 minutes and look like a hang). When the ceiling is hit the payload carries "truncated": true, "total_available", and a hint; a complete result never carries them. Pass --all to fetch every record, or set --max-records/--max-pages explicitly. --all cannot be combined with those two flags.

Some endpoints ignore paging entirely (/CRMNote answers every page_no with page 1). The list loop detects the repeated page, never appends it, recovers the full set with one count=<total> request, and reports "paging_ignored": true — so a lying pager can no longer duplicate rows (the /CRMNote case went from 150 items/50 distinct to 133/133, verified live).

Labels are resolved automatically. Halo returns bare foreign keys (status_id: 9, priority_id: 4) and only occasionally denormalises a name, so list, get and post-apply results hydrate the tenant's own label beside every resolvable id — status_name: "Closed", priority_name: "Low" — using a bounded lookup read per entity (detail-fetch fallback for ids past the first page). Raw ids are never touched, labels Halo already sent are never overwritten, and table output prefers the label column. Opt out with --no-labels. Write previews stay zero-network by contract, so they show the ids that go on the wire; read the labels with get/list. Quirks: priorities are GUID-keyed while tickets store integers (joined via priorityid, since /Priority/<int> 404s), and unresolvable ids (e.g. external-system references) simply stay bare.

The reports resource also declares two nested operations, both verified live against the tenant:

halocli reports run 147 --limit 20                  # execute; row_count/columns/items
halocli reports clone 147 --name "A copy"           # preview (zero network)
halocli reports clone 147 --name "A copy" --apply --yes   # create it

reports run exits non-zero when Halo cannot execute the query — note that Halo reports SQL failures with HTTP 200 and the error buried in the payload — and warns when the result reaches Halo's 50,000-row cap. Execution is deliberately not retried: Halo's gateway answers 504 at roughly 60s, so a retry would re-run the same expensive query instead of failing fast.

Resources with nested endpoints also expose them as first-class commands (halocli <resource> --help lists them with method, path and summary):

halocli tickets zapier                        # GET /Tickets/zapier
halocli invoices lines                        # GET /Invoice/lines
halocli invoices pdf 42 --save invoice.pdf    # POST /Invoice/PDF/{id}, binary -> file
halocli quotations lines --data lines.json --apply --yes   # POST /Quotation/Lines (array body)
halocli attachments get-image 7 --save img    # GET /Attachment/image/{id}
halocli attachments upload-image --file pic.png --apply --yes

These are generated from ResourceOperation metadata, so every path and method is verified against the vendored spec by the coverage oracle. Behaviour worth knowing:

  • Path arguments are arity-checked — a missing or extra positional fails with usage (exit 2) before anything is sent.
  • Reads never take --apply/--yes; writes preview by default (zero network, no profile needed) and require --apply --yes to execute.
  • Binary responses (PDFs, images) are never dumped into JSON: pass --save PATH to write them to a file, otherwise you get byte counts.
  • Verification provenance appears in --help per operation: live, live:403/live:500/live:404 (probed against the real tenant), route-verified (route confirmed, no live data), or spec (spec-documented, not probed — writes are never fired at a tenant without an operator).

Sixty-four resources carry write metadata and first-class write commands: the ticketing core (tickets, actions, statuses, priorities, kb, canned-text), the people/CRM layer (clients, sites, assets, agents, appointments, users, crm-notes, timesheet-events), writes-batch-1's config/reference set — tags, outcomes, categories, ticket-types, ticket-areas, releases, release-types, email-templates, faq-lists, cost-centres, budget-types, cabs, call-scripts, qualifications, asset-groups, asset-types, item-groups, item-stocks, stock-bins, service-categories, services, pdf-templates and to-do-groups — and writes-batch-2's set: organisations, teams, suppliers, slas, workdays, products, fields, field-groups, field-infos, custom-tables, holidays and lookups - plus writes-batch-3's set: crm-note-replies, certificates, email-template-variables, release-note-groups, release-pipelines, ticket-type-groups, item-suppliers, product-components, and the POST-only tier (agent-check-ins, call-log, to-dos - the spec offers no DELETE /{id}, so the CLI withholds the command) - plus the tackle-the-25 set: address, contact-groups, contact-group-contacts and contract-schedule-plans. All batches' routes are spec-verified (POST on the collection, DELETE /{id}) but were never fired at a tenant (verification: spec); their single required create field is the observed primary column — a documented house assumption, since the spec declares no required fields anywhere. users carries the live-proven create set plus account actions — including the end-user MFA reset:

halocli users update <id> --data '{"_revoke_authenticatorapp": true}' --apply --yes
halocli users update <id> --data '{"resetpassword": true}' --apply --yes
halocli users create --data user.json              # firstname, surname, name,
                                                  # emailaddress, client_id, site_id

Passwords passed via --data are masked as *** in preview and result output (the request itself carries the real value). contracts had deferred its writes pending an operator decision; the tackle-the-25 pass (2026-10-02) resolved it: the contract core is now argued-raw with final, schema-cited reasons (creation carries prepay auto-topup fields and _send_appointment_invites/_send_outstanding_emails flags; approval carries a signature/token; POST /SupplierContract sits behind a 403 scope), while contracts next-ref is first-class and the contract visit-plan children gained full CUD. Contact and address writes (contact-groups, contact-group-contacts, address) joined the same pass. Writes are preview by default: without flags they validate the payload and print what would be sent, with zero network calls. Executing requires both --apply and --yes:

halocli tickets create --data payload.json                      # dry-run preview
halocli tickets create --data payload.json --apply --yes        # executes
halocli tickets update 123 --data payload.json --apply --yes
halocli tickets delete 123 --apply --yes

Raw write-capable requests require both --apply and --yes:

halocli raw POST /Tickets --data payload.json --apply --yes

raw validates the method, path and body against the vendored OpenAPI spec before sending. Unknown endpoints and missing required body fields are refused; pass --no-validate to bypass the check (spec warnings are returned as spec_warnings either way).

Endpoint Coverage

Every operation in the vendored spec (1,455 of them) carries an explicit disposition in coverage_ledger.json — coverage is enforced, not aspirational:

Disposition Meaning Count
first-class reachable as a real halocli command today 506
backlog promotion candidate or unprobed — reason in note 8
deliberately-raw argued to stay raw (billing risk, integrations, secrets) 908
dormant known-dead on this tenant (live evidence in note) 10
junk vestigial/duplicate in the spec 23
python scripts/build_coverage_ledger.py          # regenerate after spec/policy changes
python scripts/build_coverage_ledger.py --check  # what CI runs; exit 1 if stale
python scripts/prod_get_sweep.py                 # re-probe every GET (read-only)

Classification lives in coverage_policy.json (segment-level, human-owned, plus per-operation overrides for live-evidence flips). A spec re-vendor that introduces an unclassified segment fails CI until a human decides — the ledger is the spine of the endpoint-promotion effort.

Live evidence: sweep_results.json holds the 2026-10-02 production GET sweep — all 797 GET operations probed read-only (358 × 200, 292 route-verified via validation-400, 149 backlog candidates now live-200, plus the 401/403/404/ 500/timeout findings that produced the dormant flips above). The sweep runner is structurally write-free (GET-only code path).

Endpoint Discovery

Two kinds of search, split at 1.0.0:

halocli search backup                    # LIVE tenant search: tickets, articles,
                                         # clients, users, assets, services
halocli search server --limit 10         # shape the output (row_count/entity_counts)
halocli search server --count-per-entity 20   # per-entity cap (server default ~5)

halocli catalog invoice                  # OFFLINE: registry + vendored spec discovery
halocli catalog "site" --limit 5

Breaking in 1.0.0: halocli search used to mean offline discovery — that is now halocli catalog. A live-search invocation with more than one term fails with this migration hint rather than querying the tenant. catalog searches both the resource registry and the vendored HaloPSA OpenAPI spec (927 paths) offline:

Refresh the vendored spec whenever Halo revs its API:

python scripts/vendor_halo_spec.py

The refresh is not a plain copy: the vendor script also enriches the spec so it is usable by typed consumers (Forge, Fern, OpenAPI tooling), which otherwise choke on Halo's near-empty metadata (upstream ships 3 operationIds for 1455 operations):

  • missing operationIds are synthesized deterministically as {method}_{path} (e.g. get_invoice_pdf_id); upstream IDs are kept;
  • src/halocli/spec/halo_overlay.json (OpenAPI Overlay 1.0.0) fills prose the upstream spec omits: summary/description for every operation the registry surfaces, query-parameter descriptions (e.g. loadreport), and schema-property descriptions (e.g. AnalyzerProfile.sql) for behaviours learned by live API testing. Applied with fill-if-missing semantics so upstream improvements survive refreshes. When the registry grows, tests/test_spec_enrichment.py fails until the overlay is extended — edit the overlay JSON directly.

Then re-run the coverage oracle to see what the new spec adds:

python scripts/coverage_report.py --top 25   # JSON report
python scripts/coverage_report.py --check    # CI gate: exit 1 on write mismatches

The oracle diffs the vendored spec against the resource registry offline. It reports first-class coverage (list/get per operation), ranks uncurated roots as curation candidates (roots of existing resources first — extending Attachment or Tickets is cheaper than adopting a new subsystem), and verifies registry promises against the spec in both directions:

  • write_mismatches — create/update/delete metadata pointing at endpoints the spec does not support (the /Contract bug class; must stay empty),
  • read_mismatches — registry endpoints absent from the spec entirely (undocumented reads that work today but nothing verifies).

tests/test_coverage.py pins the current state of both lists, so a spec refresh that changes them fails the suite and forces a re-evaluation.

Offline Mirror: sync, sql, standup, triage

halocli sync mirrors registry resources into a local SQLite database so cross-resource questions answer instantly instead of re-paging the tenant or guessing filter params (--param assigned_to silently returns all 137k tickets). Everything below reads the local file — nothing here writes to Halo, and the write contract is unchanged.

halocli sync                          # ops-relevant core, bounded (500 rows/resource)
halocli sync --all-resources          # the whole registry
halocli sync -r tickets --all         # one resource, no ceiling
halocli sql "SELECT status_name, COUNT(*) AS n FROM tickets GROUP BY status_name"
halocli triage --stale-days 7 --limit 10
halocli standup --since 24h

Design notes (evidence: mirror_evidence.json, scripts/mirror_evidence_probes.py, read-only probes):

  • JSON-first rows + per-resource views: rows live in mirror_rows; a view per resource (SELECT summary FROM tickets) projects plain-identifier keys via json_extract, and exotic keys stay reachable through the data column. Rows sharing an id across parents survive (sequence-numbered) instead of overwriting each other.
  • Sync-time label hydration bakes agent_name/status_name/ client_name into the mirror (ticket payloads ship none — proven), so digests and SQL never need mapping tables.
  • Bounded by default: 500 rows per resource, --all opts out, truncation recorded in mirror_state and echoed by triage/standup — a partial tickets mirror says so instead of implying completeness.
  • Failures keep data: a resource that errors is recorded in the summary; its previous rows stay (a transient 500 must not wipe the mirror).
  • sql is SELECT-only against the local file — single statement, conservative keyword guard, --limit reported not implied. It never reaches Halo; Halo's server-side SQL surface stays deliberately raw — this is not that.
  • Tenant-specific facts proven live: status ids are not portable (no hardcoded closed-id set — names are configurable via --closed-status), dateclosed drives "closed in window", /Feed must never be page-walked (page 2 = page 1 verbatim, page_size ignored), and /Actions unfiltered times out (excluded from the core sync set).

Ideas adapted from Servosity's msp-skills halopsa CLI (Apache-2.0) — the SELECT-only guard stance and the hand-written digest concept; every field note above was re-proven against our own tenant.

MCP Server (Code Mode)

halocli serve runs a code-mode MCP server over stdio (newline-delimited JSON-RPC 2.0, no extra dependencies). Instead of exposing one tool per Halo operation — which floods the model's context — it exposes exactly three tools with rich descriptions:

Tool Purpose
halo_search Discover resources/operations (registry + OpenAPI)
halo_execute Run one REST call with server-side guardrails
halo_resources Dump the full resource catalog when search misses

Guardrails are enforced server-side: non-GET methods require apply: true (otherwise you get a structured refusal and no request is sent), and responses are bounded at ~40,000 characters so a large collection cannot flood the context window.

Register it with an MCP client (example for a generic MCP config):

{
  "mcpServers": {
    "halo": {
      "command": "halocli",
      "args": ["serve"]
    }
  }
}

halocli --version measures ~1.1s on cold start (Python + Typer import); the OpenAPI spec loads lazily and is not part of that cost. The MCP server is a long-lived process, so its startup is paid once.

Todo And Microsoft To Do Preview

HaloCLI includes a slim Todo surface for experimenting with lightweight Halo work items without Bifrost. Microsoft To Do access uses the shared mtg-microsoft-auth backend and defaults to read-only Tasks.Read.

Live Microsoft To Do import requires the optional auth backend:

python -m pip install -e ".[microsoft-todo]"
$env:TODO_CLIENT_ID = "e02be6f7-063a-46a6-b2cc-109d5f51055c"
$env:TODO_SCOPES = "Tasks.Read"
halocli todo import-ms --max-records 10

Preview from captured JSON instead of live Graph:

halocli todo import-ms --source-json microsoft-todos.json

Create a lightweight Halo Todo backed by Halo's Appointment API (preview first, like every write — pass --apply --yes to fire):

halocli todo add "Independent todo list front end for HaloPSA" --owner 37 --due 2026-04-26 --tag microsoft-todo --tag halo-todo --apply --yes

List, inspect and complete Halo todos (these are Appointment rows with is_task — the API's own /ToDo table is empty on this tenant, so to-dos list returns nothing while todo list is the real surface):

halocli todo list                       # open tasks, server-side filters
halocli todo list --mine --max-records 50
halocli todo list --status done         # or: --status all
halocli todo get 38790
halocli todo complete 38790             # preview: shows the exact payload
halocli todo complete 38790 --apply --yes

Halo's completion convention (proven live,331 tasks): complete_status is 0 = done, -1 = open — list filters server-side with tasksonly/hidecompleted and pages properly, so todos beyond the first page of appointments are not silently invisible.

Run the local-first Todo HTTP API from the same HaloCLI profile:

python -m pip install -e ".[web]"
halocli todo web --profile midtown --host 127.0.0.1 --port 8766

The server exposes normalized Todo JSON over Halo appointment tasks — /api/todos, /api/clients, /api/tickets and /api/me — with interactive docs at /docs. HaloPSA remains system of record; the API does not create a local database. HaloCLI no longer bundles a browser UI (removed in 0.11.0): / returns service info so any external front end can discover the endpoints.

Todo priority is currently stored as HaloCLI metadata in the backing appointment note_html; it is not mapped to Halo ticket priority or a native Halo appointment priority field.

Bifrost Compatibility

This package does not import Bifrost. Bifrost workflows can shell out to halocli when a direct HaloPSA operator path is useful, or a future optional backend package can bridge to Bifrost-specific auth/runtime behavior.

The Bifrost workspace may keep its own Bifrost-backed helper while this package stays portable.

License

HaloCLI is released under the GNU General Public License v3.0. See LICENSE for details.

See THIRD_PARTY_NOTICES.md for attribution to netaryx/pyhalopsa, which served as prior art for this project.

Windows MSI

Tagged releases build a per-machine Windows MSI that installs halocli.exe under Program Files and adds that install directory to the system PATH. Installing or uninstalling the MSI requires an elevated prompt.

About

Standalone HaloPSA CLI for safe operator and automation workflows

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages