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.
From the latest GitHub release tag:
pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0If you use uv:
uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0From 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.
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-credentialsDo not commit profile files or secrets.
HaloCLI can safely discover whether a Halo instance exposes CLI-usable authorization-code style endpoints:
halocli auth discover --tenant-url https://yourtenant.halopsa.comIf discovery does not confirm an authorization endpoint, keep using client-credentials for API automation:
halocli configure --profile thomas --auth-mode client-credentialsYou 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 thomasProfile 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.
Halo gates API access on two independent layers, both of which must pass:
- the API application's Permissions tab (OAuth scopes — these are fixed at
token issuance, so a refresh never widens them; re-run
halocli auth loginafter changing them); - 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 /TicketsA 403 response includes a diagnostic pointing at this command. --check
performs a GET with take=1 — it never issues a write.
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=ExampleHaloCLI 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 IDUndocumented 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 itreports 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 --yesThese 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 --yesto execute. - Binary responses (PDFs, images) are never dumped into JSON: pass
--save PATHto write them to a file, otherwise you get byte counts. - Verification provenance appears in
--helpper operation:live,live:403/live:500/live:404(probed against the real tenant),route-verified(route confirmed, no live data), orspec(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_idPasswords 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 --yesRaw write-capable requests require both --apply and --yes:
halocli raw POST /Tickets --data payload.json --apply --yesraw 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).
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).
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 5Breaking 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.pyThe 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/descriptionfor 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.pyfails 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 mismatchesThe 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/Contractbug 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.
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 24hDesign 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 viajson_extract, and exotic keys stay reachable through thedatacolumn. Rows sharing an id across parents survive (sequence-numbered) instead of overwriting each other. - Sync-time label hydration bakes
agent_name/status_name/client_nameinto the mirror (ticket payloads ship none — proven), so digests and SQL never need mapping tables. - Bounded by default: 500 rows per resource,
--allopts out, truncation recorded inmirror_stateand echoed bytriage/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).
sqlis SELECT-only against the local file — single statement, conservative keyword guard,--limitreported 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),datecloseddrives "closed in window",/Feedmust never be page-walked (page 2 = page 1 verbatim,page_sizeignored), and/Actionsunfiltered 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.
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.
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 10Preview from captured JSON instead of live Graph:
halocli todo import-ms --source-json microsoft-todos.jsonCreate 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 --yesList, 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 --yesHalo'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 8766The 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.
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.
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.
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.