This release is a pre-release and may not be stable for production use.
🦊 hfox
A command-line interface for the HappyFox REST API.
hfox covers tickets, contacts, contact groups, assets, asset types and reference data
with a noun verb structure modeled on the Google Workspace CLI (gws).
Humans get --help, tables and --dry-run; scripts and AI agents get JSON by default,
structured errors and stable exit codes.
Install / run
hfox is a uv tool and requires Python 3.11+.
uv tool install hfox # install it as a persistent tool
uvx hfox --help # or run it without installing
uvx --from . hfox --help # run from a checkout
Each GitHub release also attaches single-file
binaries for Linux, macOS and Windows that need no Python. Mark a download executable with
chmod +x; on macOS, a browser download also needs xattr -d com.apple.quarantine <file>.
Every binary, the wheel and the sdist carry a build provenance attestation, checked with
gh attestation verify <file> --repo leoherzog/hfox.
Authenticate
hfox needs an API key and its auth code. In HappyFox, open Apps → Goodies → API,
click Enable, add a key with +, then hover its row and click see auth code.
hfox auth login # prompts for subdomain, region, API key, auth code
hfox auth login --subdomain acme --email agent@acme.com # also sets your default staff id
HFOX_API_KEY=... HFOX_AUTH_CODE=... hfox auth login --subdomain acme # scripted
hfox auth status --check
hfox auth logout
Login checks the credentials against staff/ before saving them. It takes each value from
its flag, then from HFOX_SUBDOMAIN, HFOX_REGION, HFOX_API_KEY or HFOX_AUTH_CODE, then
from a prompt on stderr. Keep the two secrets off the command line, where the shell history
and the process list expose them. When stdin is not a terminal, a missing subdomain, key or
code exits 3 instead of prompting, and a missing region means us. The error names the
missing flags and the variable to set for each one.
--email saves the id of the one agent with that email as the default staff id. Without
--email, a login to the same subdomain, region and base URL keeps the stored default, and a
login to a different target removes it. An --email that matches nobody, matches several
agents or matches an agent with an unusable id sets no default and removes a stored one; the
login still succeeds. default_staff_id in the login JSON is the value stored
after the save.
Each auth command prints one JSON document that never holds a secret. auth status masks
the API key and exits 2 when credentials are missing, after printing its payload.
auth status --check sends one request and adds verified; a failed check adds
check_error and exits with that error's code. A staff/ answer that is not a list fails
the check with exit 5.
Configuration
The config directory is $XDG_CONFIG_HOME/hfox when that variable holds an absolute path,
otherwise ~/.config/hfox, on every OS. --config-dir or HFOX_CONFIG_DIR overrides both.
| File | Contents |
|---|---|
token.json |
secrets: api_key, auth_code, subdomain, region, optional base_url (mode 0600) |
config.toml |
non-secret: subdomain, region, default_format, default_staff_id |
Every field resolves as environment variable, then file, then default. config.toml also
accepts a hand-written base_url, read after HFOX_BASE_URL and token.json.
A file that cannot be parsed, or that holds a non-string subdomain, region, base_url,
api_key or auth_code, exits 5 with an error of type config that names the file and the
key. When no home directory can be determined, set HFOX_CONFIG_DIR to an absolute path; a
config directory whose ~ cannot be expanded also exits 5.
| Variable | Sets |
|---|---|
HFOX_SUBDOMAIN, HFOX_REGION |
the account host |
HFOX_API_KEY, HFOX_AUTH_CODE |
the credentials |
HFOX_BASE_URL |
an http(s)://host root for a proxied account |
HFOX_STAFF_ID |
the default acting staff id |
HFOX_FORMAT |
the default output format |
HFOX_TIMEOUT, HFOX_MAX_RETRIES |
the defaults of --timeout and --max-retries |
HFOX_CONFIG_DIR |
the config directory |
--region eu targets *.happyfox.net; a custom domain goes in --subdomain as the full host
(support.acme.com). For proxied accounts, set HFOX_BASE_URL; a trailing /api/1.1/json
is stripped and auth login stores the value. A stored base URL wins over --subdomain, so
auth login --subdomain warns on stderr when one is in effect.
A base URL with embedded credentials, a query or a fragment exits 3. The error leaves out any
value that holds @, ? or #, since it may carry a secret. An http base URL on a
non-loopback host sends the credentials in cleartext, so hfox warns on stderr once per
invocation and proceeds.
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY are honored through httpx. TLS trust
comes from the certifi bundle unless SSL_CERT_FILE or SSL_CERT_DIR names another one.
Redirects are never followed.
SECURITY.md describes the
credential model.
Global flags
Global flags go before the resource: hfox -f table tickets list, not hfox tickets list -f table.
| Flag | Purpose |
|---|---|
-f, --format {json|table|csv|yaml} |
output format (default json) |
--dry-run |
print the exact request without sending it |
--page-all |
fetch up to --page-limit pages from --page; JSON streams NDJSON, one page per line |
--page-limit N / --page-delay MS |
bound --page-all (default 10 pages / 100 ms) |
--staff TEXT / --staff-id N |
acting staff for write actions, by email or name, or by id |
--timeout SECONDS |
per-attempt timeout for connecting and for each read or write (default 30) |
--max-retries N |
retries after HTTP 429 or a network error (default 5) |
--quiet |
suppress status messages and retry notices |
--config-dir DIR |
override the config directory |
--no-color |
disable color (also honors NO_COLOR) |
-v, --version |
show the release version, or dev when run from source |
A global flag after the resource exits 3 with an error of type usage that says where the
flag goes. That also holds when the flag follows an option that takes a value, so
tickets note 5 --text --dry-run is refused. Write --text=--dry-run to send such a literal.
A global option that takes a value refuses one that starts with -, so
hfox --staff --dry-run tickets list exits 3 with type usage instead of reading --dry-run
as the staff name. The --opt=value form is checked the same way. A numeric option such as
--timeout still takes a negative number, which its range check then rejects.
An unknown format from --format, HFOX_FORMAT or default_format exits 3. In config.toml
only an empty string means unset, so default_format = false is an unknown format.
--timeout bounds one attempt, not the whole command. Retries, their backoff and any
Retry-After wait come on top of it. Before each retry hfox prints one stderr line with the
reason and the wait. GETs retry any network error; writes retry only when no connection was
made.
Listing and search
For full exports, pass --size 50 with --page-all. When --page-limit stops the walk
early, hfox warns on stderr and still exits 0, so raise the limit for a complete export. On
tickets list, add a stable --sort such as ticketa; -q searches always sort by
relevance.
On tickets list and contacts list, -q means --query:
- Tickets: text or filters such as
status:"In Progress","New"; a comma means any-of and a space replaces the docs'+. Multi-word text is ANDed, which HappyFox does not document. - Contacts:
field:valuefilters onname,email,phone,updated_sinceorcreated_since, ANDed when space-separated. Omit+from phone numbers.
Endpoints without server-side search take local filters instead. Every system command and
contacts groups list take --name, and system staff also takes --email. A filter keeps
rows whose field contains the text, ignoring case; every filter must match, and a missing or
non-text field never matches. Filters are never sent to HappyFox, and no match prints an empty
listing with exit 0.
assets list, assets types list and assets custom-fields list take --page and --size.
They take --name only with the global --page-all, since one page would miss matches. Each
NDJSON line keeps the server's page_info, which counts rows before filtering.
Acting staff
Commands that HappyFox records against an agent take --staff or --staff-id, never both.
--staff is an email or a name and --staff-id the numeric id. The pair is on ticket
reply, note, update, update-cf, tags, forward, move and delete, and on asset
create, update and delete. On tickets subscribe and unsubscribe the pair names the
agent being added or removed.
The identity resolves in this order: the flag on the command, the global flag, then
HFOX_STAFF_ID or the default saved by auth login --email. --staff reads staff/ and
matches the email first, then the name, ignoring case. No match or several matches exit 3,
and a value made of digits is refused with a pointer to --staff-id. The lookup is a read,
so it is also sent under --dry-run and needs credentials.
--assignee, --assign-to and --agents take staff ids; --alert takes s, c or a
staff id. None accepts an email or name. Find the ids with hfox system staff.
Input from files and stdin
Message bodies can come from a file: --text-file and --html-file on tickets create,
reply and note, --text-file on user-reply, --message-file on forward and
--note-file on move. Pass - to read stdin. The inline flag and its file form exclude
each other, and a file or stdin body that is empty or whitespace only exits 3.
The JSON --file flags of tickets create-bulk, contacts create-bulk and
tickets set-cf-choices accept - as well. Only one input per invocation may read stdin.
Files and stdin are read as strict UTF-8, and a leading byte order mark is dropped.
--attachment always names a file.
A path must name a regular file. A directory, FIFO, device or process substitution exits 3
with "is not a regular file", and a missing path exits 3 with "not found". A leading ~ that
names no known home directory is used as written.
hfox refuses to read a file you name when it lies inside the config directory, including
through a symlink or a hard link to token.json. It also refuses to send a file or stdin
whose content holds the configured API key or auth code. Both checks run under --dry-run.
Confirmation
tickets delete, assets delete and tickets set-cf-choices ask before acting, on stderr.
--yes skips the question, and --dry-run never asks. When stdin is not a terminal the
command exits 3 unless --yes is given. Declining, or pressing Ctrl-C at the prompt, prints
an error of type cancelled and exits 5.
Output
JSON is the default; -f table, -f csv and -f yaml render the same data. Status lines,
warnings and prompts go to stderr, so stdout stays parseable. CSV ends every row with one
CRLF on every platform, and a newline inside a cell stays a bare LF.
Table and CSV cells drop control, bidi and zero-width characters, so ticket text cannot drive
the terminal. Newline, tab and the zero-width joiner and non-joiner are kept. A CSV text cell
that starts with =, +, -, @ or a tab gets a leading apostrophe so a spreadsheet does
not run it as a formula; plain signed decimals such as -5 and +15551234567 stay as they
are.
JSON and NDJSON write C1 controls, bidi controls, line and paragraph separators and tag
characters as \uXXXX escapes. A JSON parser returns the same strings, and YAML carries the
data unchanged.
Errors and exit codes
| Code | Meaning |
|---|---|
0 |
success |
1 |
HappyFox returned an error response, or part of a bulk request failed |
2 |
missing or rejected credentials |
3 |
bad arguments or input |
4 |
resource not found (HTTP 404) |
5 |
anything else: network, timeout, config file, cancelled prompt, internal |
A failure prints one JSON object on stdout:
{
"error": "Rate limit exceeded (HTTP 429). HappyFox enforces a 10-minute cooldown after the limit is hit.",
"type": "rate_limited",
"exit_code": 1,
"status_code": 429,
"retry_after": 600
}
error, type and exit_code are always present. status_code, detail, hint,
retry_after and outcome_unknown appear only when set. type is one of api,
rate_limited, auth, validation, usage, not_found, network, timeout, config,
cancelled, internal and other. usage is a structural mistake such as an unknown or
misplaced flag, a missing argument or a global option without its value, and validation is
a rejected value.
retry_after is the server's Retry-After in seconds on a 429 that outlasted the retries.
outcome_unknown: true marks a write that failed after the request may have left, or that
HappyFox answered with HTTP 500 or above. The write may have been applied, so check the
resource before sending it again. A failure to connect, a pool timeout, a proxy error and an
unsupported URL scheme never set it, and neither does a GET.
A nonzero exit carries that error object except in these cases:
- A partial bulk or group-membership failure prints the response and exits 1. Removing a contact that is not in the group does not count.
auth statuswithout credentials prints its payload and exits 2. A failedauth status --checkprints the payload withcheck_error.- With
--page-allin JSON, the error is the last NDJSON line. - Ctrl-C outside a prompt exits 130 with nothing on stdout.
Usage errors also print the usage hint on stderr, except a misplaced global flag and a global
option without its value. Bare group invocations (hfox tickets) print help and exit 0.
Stability
These are contract from 1.0 on: the JSON that hfox itself produces, CSV output, exit codes,
flag names and environment variable names. The hfox JSON is the error object, the
--dry-run preview {dry_run, method, url, params, body, attachments}, the auth payloads
and the NDJSON framing of one page per line. Truncation at --page-limit keeps exit 0 and
the stderr warning.
These are not contract: table layout, the files inside the config directory, the bodies
HappyFox returns and hfox passes through, and the content of detail.
Minor releases may add keys, type values, flags and environment variables, so ignore the
ones you do not know. Removing or renaming any of them is a breaking change. The names
--columns, --profile and HFOX_PROFILE are reserved.
Command overview
hfox auth login | status | logout
hfox tickets list | get | create | create-bulk | inline-attachment | reply | note | user-reply
update | update-cf | tags | subscribe | unsubscribe | forward | move | delete
set-cf-choices
hfox contacts list | get | create | update | create-bulk
hfox contacts groups list | get | create | update | add-contacts | remove-contacts
hfox assets list | get | create | update | delete
hfox assets types list | get
hfox assets custom-fields list | get
hfox system categories | priorities | staff | statuses | ticket-custom-fields | contact-custom-fields
Ticket commands take the numeric ticket id, not the display id. tickets update changes
status, priority, assignee, due date, tags, time spent and custom fields without posting a
message, and tickets create --unassign sends an explicit empty assignee. contacts create
also edits the contact with the same email and resets custom fields it does not send.
Examples
# Reference data (ids you need elsewhere)
hfox -f table system priorities
hfox -f table system ticket-custom-fields # choices shown as text=id
hfox system staff --email jane@ # find a staff id
# Tickets
hfox -f table tickets list --status _all --size 50 --fields id,display_id,subject
hfox --page-all tickets list -q 'priority:"CRITICAL"' --size 50
hfox tickets list -q 'id:DC00000003' # find the numeric id of a display id
hfox tickets get 1234 --show-cf-changes
hfox tickets create --subject "Printer down" --category 3 \
--name "Han Solo" --email han@rebels.org --text "It is on fire." \
--cf 7=Urgent --attachment ./photo.png
hfox tickets reply 1234 --text "On it." --status 5 --update-customer
hfox tickets reply 1234 --html-file reply.html --staff jane@acme.com
git log -5 | hfox tickets note 1234 --text-file -
hfox tickets update 1234 --status 3 --unassign # change properties without a message
hfox tickets inline-attachment ./diagram.png # temporary url for an <img src>
hfox tickets move 1234 --to-category 4 --note "Wrong queue"
hfox --dry-run tickets delete 1234 # preview, no prompt
hfox tickets delete 1234 --yes
# Contacts & groups
hfox -f table contacts list -q name:han
hfox contacts create --name "Leia" --email leia@rebels.org --cf 4=VIP
hfox contacts create-bulk --file - < contacts.json
hfox contacts groups add-contacts 12 --contacts 41,200 --access-tickets
# Assets
hfox -f table assets list --asset-type 1
hfox --page-all -f table assets list --asset-type 1 --size 50 --name latitude
hfox assets types list --page 2 --size 50
hfox assets create --asset-type 1 --name "MBP-14" --display-id "LAP-001" \
--cf 5=4 --cf '6=[3,4]' --staff-id 12
Custom fields
--cf <id>=<value> is repeatable. Canonical decimals become numbers, <id>=[a,b] sends a
list, and anything else is sent as the exact string.
--cf 3="In Progress" # text field
--cf 5=4 # dropdown choice id
--cf '6=[3,4,5]' # multiple-option ids -> [3,4,5]; '6=[4]' -> [4]
--cf 7=2025-12-25 # date
--cf 8=02134 # stays the string "02134"
An all-blank value such as --cf 5= exits 3. --cf-json '{"5": ""}' sends values uncoerced.
With --attachment, null values are dropped, or rejected on reply and note. tickets create,
reply and note take contact fields through --contact-cf and --contact-cf-json.
Keys are numeric ids from hfox system ticket-custom-fields, contact-custom-fields or
hfox assets custom-fields list, not agent-portal URLs. A key may carry its endpoint's
prefix (t-cf-5); any other key exits 3.
Custom-field choices
tickets set-cf-choices <field_id> replaces the whole choice list of a ticket dropdown or
multiple-option field. The choices come from --file, stdin or --choices-json, as a JSON
array or as an object {"choices": [...]}.
hfox tickets set-cf-choices 7 --yes \
--choices-json '[{"id": 11, "text": "Low"}, {"id": 12, "text": "High"}, {"id": null, "text": "Urgent"}]'
Every choice needs a non-blank text and an id. An existing choice keeps its id, a new one
has "id": null, and an existing choice left out of the list is deleted. Read the current
ids with hfox system ticket-custom-fields first and carry each one you want to keep. The
command states how many choices are kept and how many are new before it asks.
Architecture
src/hfox/
├── core/ # HTTP client, config/secrets, errors and exit codes; no Typer, no printing
└── cli/ # Typer apps and output formatting, one module per resource
Development
uv sync # set up the environment
uv run hfox --help # run from source
uv run python -m pytest # offline tests
uv run ruff check . # lint
uv build # build sdist + wheel
Read AGENTS.md before extending the CLI. skills/hfox/SKILL.md is a usage guide for AI agents that drive it.
Metadata
Release files for hfox 1.0.0rc1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hfox-1.0.0rc1.tar.gz | 133.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hfox-1.0.0rc1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 193.6 kB
Release files / hfox-1.0.0rc1.tar.gz
| Download URL | hfox-1.0.0rc1.tar.gz |
|---|---|
| Size | 133.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ff563d752311b5dffb9fdb5457fbe66cfbbb0a04d16420e452880a4ac098f541
|
|
BLAKE2b-256 checksum How to use checksums |
0985c0b1a6a5a186e5cc1e3fa50784fdc67fff515d053a14a02f341984c537a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.21 {"installer":{"name":"uv","version":"0.12.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / hfox-1.0.0rc1-py3-none-any.whl
| Download URL | hfox-1.0.0rc1-py3-none-any.whl |
|---|---|
| Size | 60.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8fe4366c6f21bd7eea9d4bbf559720dbff1f3d165491359b657b8dcd0bb3c228
|
|
BLAKE2b-256 checksum How to use checksums |
c42ec16ee20b42f16bc0f98e7d56106574ef6f65288cc60e5349a68a3151578b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.21 {"installer":{"name":"uv","version":"0.12.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|