A Freshdesk helpdesk CLI built for agent runtimes: read-only evidence gathering, plus four guarded, single-shot writes — one public reply, and tag, field and close changes on the ticket.
Every command answers with a single JSON envelope. Every failure carries a distinguishable
error.type and a distinct exit code. Collections are all-or-nothing. And the one command
that can write something a customer will read is fenced behind guardrails that live inside
the tool rather than in the caller's good intentions.
The whole design shows up in one sequence — four attempts to send the same reply, and the three that must not go out:
$ freshdesk reply send --ticket 4417 --body-file reply.html --baseline "$BASELINE"
{"ok": false, "error": {"type": "confirmation_required",
"message": "reply send requires confirmation", "hint": "add --yes to confirm"}}
exit 10 # nothing sent — and zero API calls, the gate runs first
$ freshdesk reply send --ticket 4417 --body-file reply.html --baseline "$STALE" --yes
{"ok": false, "error": {"type": "guard_evidence_changed",
"message": "ticket evidence changed since the baseline was taken",
"hint": "re-read the ticket, recompose against the new evidence, then send with the new baseline",
"detail": {"baseline_fingerprint": "u:2026-08-27T08:40:11Z|c:90207",
"current_fingerprint": "u:2026-08-27T09:12:44Z|c:90210",
"new_conversation_ids": [90210]}}
exit 7 # the customer wrote in again while the draft was being written
$ freshdesk reply send --ticket 4417 --body-file reply.html --baseline "$BASELINE" --yes
{"ok": true, "data": {"result": "confirmed", "reply_id": 90211,
"created_at": "2026-08-27T09:31:02Z"}}
exit 0 # exactly one POST, never retried
$ freshdesk reply send --ticket 4417 --body-file reply.html --baseline "$BASELINE" --yes
{"ok": false, "error": {"type": "guard_duplicate",
"message": "an identical public reply already exists on this ticket",
"hint": "nothing was sent; the reply is already on the ticket — do not recompose and do not send again",
"detail": {"conversation_id": 90211, "created_at": "2026-08-27T09:31:02Z"}}}
exit 7 # the retry a naive wrapper would have let throughNote the last two: the baseline no longer matches either, since the sent reply raised the latest conversation id — but the duplicate check runs first, so the answer is the actionable one (already sent, stop) instead of the misleading one (re-read and recompose). That ordering is load-bearing, not incidental.
Real CLI output, captured against a mock API server; set FRESHDESK_BASE_URL to point the
binary at your own and reproduce it. The CLI pretty-prints one object per invocation — the JSON
above is reflowed, and a couple of detail fields are elided, to keep the block narrow.
Wrapping the Freshdesk API is easy. Wrapping it so that a language model can drive it without doing damage is the actual problem, and it comes down to three failure modes that a thin wrapper reproduces faithfully:
- A failure that looks like an empty result. A rate limit, a permission error, or a
half-read page turns into "the queue is empty," and the agent reports all clear. Here,
those are five distinct exit codes and
scannever emits a partial set — if the complete set could not be collected, the command fails and returns no tickets at all. - A reply sent twice. The POST times out, the agent retries, the customer gets two
copies. Here, the reply POST is issued exactly once and is never retried; an unknown
outcome stays unknown and is resolved by a separate read-only
verify. - A reply composed against stale evidence. The customer wrote in again while the draft was being written. Here, sending is refused unless the ticket still matches the fingerprint the draft was built from.
The write surface is four commands and no fifth — one public reply, plus tag, update and
close on the ticket itself. There are no private notes and no delete. ticket update can set
status, priority, assignee, group and custom fields, but it refuses --status 5, so closing a
ticket has exactly one door and that door checks the baseline.
It is also tenant-agnostic: queue names, status names, and agent names come from a
directory file injected at runtime with --directory. No business data lives in this
repository.
go install github.com/Dynmi/freshdesk-cli/cmd/freshdesk@latestRequires Go 1.24+. The binary lands in $(go env GOPATH)/bin.
Or take a prebuilt binary from the Releases
page — freshdesk-linux-amd64 and freshdesk-darwin-arm64 are attached to each release.
Download, chmod +x, put it on your PATH. Other platforms build from source with the
go install line above.
Two environment variables:
export FRESHDESK_DOMAIN=example.freshdesk.com # no scheme
export FRESHDESK_AUTH_B64="$(printf '%s:X' "$API_KEY" | base64)"FRESHDESK_AUTH_B64 must already be base64-encoded. The CLI never encodes it — it sends
Authorization: Basic $FRESHDESK_AUTH_B64 verbatim, byte for byte. This matters in sandboxed
deployments where the variable holds a placeholder that an egress proxy swaps for the real
credential on the wire; the CLI cannot tell the difference and must not try. Set
FRESHDESK_BASE_URL to override the base URL when testing against a fake server.
Then, in the order you would actually use them:
# 1. Validate the directory file and resolve business aliases to numeric IDs. No API calls.
freshdesk catalog resolve --directory dir.yaml --queues "Main Queue"
# 2. Collect the complete ticket set for one agent × queues × statuses.
freshdesk scan --directory dir.yaml \
--input '{"responder":"Some Agent","queues":["Main Queue"],"statuses":["open"]}'
# 3. Build a one-ticket evidence bundle: metadata, full conversation history, attachments.
freshdesk bundle --ticket 123 --out ./evidence
# 4. Thin reads, when a bundle is more than you need.
freshdesk ticket get --id 123
freshdesk conversations list --ticket 123 --page 1
# 5. Send one public reply, against the fingerprint from step 3.
freshdesk reply send --ticket 123 --body-file reply.html \
--baseline "u:2026-01-01T00:00:00Z|c:456" --yes
# 6. Only if step 5 exited 8 (outcome unknown). Read-only. Run it exactly once.
freshdesk reply verify --ticket 123 --body-file reply.html \
--baseline "u:2026-01-01T00:00:00Z|c:456" --attempted-at 2026-01-01T00:00:05Z| Command | What it does |
|---|---|
catalog resolve |
Validates the directory file and resolves aliases to numeric IDs. Zero API calls. |
scan |
Collects the complete ticket set for responder × queues × statuses, or fails. |
bundle |
Writes a one-ticket evidence bundle to disk: manifest.json, ticket.md, conversations.md, attachments/. |
ticket get |
Reads one ticket, JSON passed through with a plain-text field filled in. |
conversations list |
Reads one page of conversations, with has_more. |
reply send |
Write. Sends one public reply behind three guardrails. Channel-agnostic: email tickets get email, Facebook/Instagram/WhatsApp DM tickets get a DM. |
ticket tag |
Write. Adds and removes tags declaratively — never a whole-list overwrite. |
ticket update |
Write. Status, priority, assignee, group and custom fields in one PUT; numeric ids only. |
ticket close |
Write. Closes the ticket; refuses if conversations arrived after the baseline. |
reply verify |
Read-only, one-shot check of whether an unknown send actually landed. |
The CLI knows nothing about your queues. catalog resolve and scan take a --directory
YAML file that maps your business vocabulary — queue names, status names, agent names, in
whatever language your team uses — onto Freshdesk's numeric group_id, status_id, and
responder_id. You write it once and keep it outside this repository, with your own data.
Resolution is exact match on registered aliases after normalization (trim + ASCII
casefold). There is no fuzzy matching, no substring matching, no translation, no spelling
correction, and numeric IDs are not accepted as input. If any single input fails to resolve,
the whole command fails with resolution_failed and lists each input's outcome — a partial
resolution is never emitted, because "the alias I did not recognize" and "the queue that is
empty" must not look alike.
See docs/directory.example.yaml for the format, and the
catalog resolve section of docs/CONTRACT.md for the validation rules.
Full details are in docs/CONTRACT.md, which is authoritative. This
is the part an integrator has to know before writing any branching logic.
Success goes to stdout, failure to stderr. One invocation prints exactly one JSON object, pretty-printed by default:
{"ok": true, "data": { ... }}
{"ok": false, "error": {"type": "…", "message": "…", "hint": "…", "detail": { ... }}}Branch on ok, or equivalently on exit code 0. hint and detail are optional and carry
the machine-actionable specifics — the conversation ID behind a duplicate rejection, the two
fingerprints behind a stale-evidence rejection, the attempted_at timestamp you need to
verify an unknown send.
| Exit | Meaning | error.type |
|---|---|---|
| 0 | Success | — |
| 1 | Transport or unexpected error | transport, internal, api_error |
| 2 | Usage error | usage |
| 3 | Target does not exist | not_found |
| 4 | Authentication or permission denied | auth, forbidden |
| 5 | Rate limited, retries exhausted | rate_limited |
| 6 | Directory, resolution, or completeness failure | directory_invalid, resolution_failed, incomplete |
| 7 | Send refused by a guardrail | guard_evidence_changed, guard_duplicate |
| 8 | Send outcome unknown | send_unknown |
| 9 | Send explicitly failed | send_failed |
| 10 | High-risk command missing confirmation | confirmation_required |
The send-related codes are the ones worth wiring carefully: 7, 9, and 10 all mean nothing was posted; 0 means it was; 8 means nobody knows.
1. Complete-set semantics. scan returns the complete matching set or it returns
nothing. It first asks for the whole range; if the server reports more than the search API's
300-result ceiling, it does not consume the truncated answer — it partitions by queue ×
status and then bisects by creation date until every leaf fits. Paging is driven by the
server's declared total, and every drained query is then reconciled against it: if the number
of distinct ticket ids that came back does not equal the declared total, the command fails
with incomplete (reason: total_mismatch) rather than reporting a set it cannot prove is
whole. Results are deliberately not post-filtered, because filtering would break exactly that
reconciliation. If any leaf still cannot be collected, or a page request fails, or the request
budget or ticket cap is exceeded, the whole command fails with incomplete and data.tickets
is not produced. Zero hits is a legitimate complete result
(total_found: 0). Note the scope of the promise: collection_complete means "every
observable match was collected," not "this is a consistent snapshot." Freshdesk's search
index is eventually consistent and has been observed running years behind the ticket record,
so tickets[].status comes from the index and may disagree with the ticket itself —
re-check with ticket get or bundle before acting on any ticket.
2. Five failures are not an empty result. Not-found (3), forbidden (4), rate-limited (5), incomplete (6), and transport failure (1) are five different outcomes, and none of them is "there are no tickets." Anything relaying these to a human must say which one it was. This is the single most common way a helpdesk automation quietly goes wrong.
3. Reads retry, writes never do. Read requests retry on 429, transient 5xx (502, 503,
504), and network errors, up to three additional attempts, honoring Retry-After up to 60
seconds. The reply POST is issued once, with a 30-second timeout, and is never retried under
any circumstance. Its outcome is
reported as exactly one of three things — confirmed (exit 0), send_failed on a definite
4xx (exit 9), or send_unknown on a timeout, network error, or 5xx (exit 8) — and those
three are never collapsed into each other. Unknown means unknown: do not guess, do not resend.
The correct response to exit 8 is a single reply verify, whose attempted_at anchor is
handed to you in the failure's detail.
This tool can send an irreversible message to a real customer, so the write path is built to be audited:
- Exactly four write paths, enumerable and greppable. The whole repository issues four
non-GET requests: the POST in
reply send, and the PUT inticket tag,ticket updateandticket close. There is no fifth. Private notes and deletion are absent, andticket updateis refused--status 5so that closing has exactly one door. - Idempotent and non-idempotent writes are treated differently. The three field commands
produce the same state when repeated, so an unknown outcome is resolved by reading the ticket
back.
reply sendis not idempotent: its unknown outcome is resolved by onereply verifyand never by resending. - The
--yesgate comes first. Without--yes,reply sendexits 10 before it reads credentials, opens the body file, or touches the network — zero API calls. - Two guardrails, in a fixed order. First a timeline duplicate check: every public
outgoing reply created after the baseline is compared to the pending body under a
normalization rule (HTML stripped, entities decoded, whitespace collapsed, no case folding),
and an exact match means
guard_duplicatewith nothing sent. Only then the baseline recheck: the current ticket fingerprint is recomputed and compared to--baseline, and a mismatch meansguard_evidence_changedwith nothing sent. The order cannot be swapped — a duplicate reply necessarily bumps the latest conversation ID, so the baseline check would always trip first andguard_duplicatewould become unreachable. Both refuse to send, but they call for opposite follow-ups: a duplicate means do nothing at all, while changed evidence means re-read and recompose. - One POST, no retry, three outcomes. Described above; the property that matters here is that no code path can post twice.
- Attachment downloads are host-restricted.
bundledownloads only fromfreshdesk.com, its subdomains, ands3.amazonaws.com, where Freshdesk's presigned attachments live. Anything else — third-party CDNs, tracking pixels in inbound email,cid:anddata:URIs — is recorded in the ledger asskipped_policywith a reason and is never fetched. The policy is applied at discovery time, so an off-allowlist URL is terminal before the download stage ever sees it, and it is re-checked on every redirect hop, so an allowed URL cannot bounce the fetch to a host outside the list. Attachment downloads carry noAuthorizationheader. - Credentials are passed through, never processed. The CLI does not encode, decode, log,
echo, or persist
FRESHDESK_AUTH_B64. It appears in exactly one place: an outbound request header. manifest.jsonholds no secrets. Presigned attachment URLs and file bytes are held in unexported fields and cannot be serialized into the manifest, so a bundle directory can be handed to a model or checked into a case file without leaking a signed URL.
See SECURITY.md for the vulnerability reporting process and what is in scope.
skills/freshdesk/ is an optional companion: an Agent Skills package (SKILL.md plus
references/) that gives a model the operating discipline the CLI cannot enforce from
inside a single process. The CLI can guarantee that it never posts twice in one invocation;
it cannot stop a caller from invoking it twice. So the skill pack is where the procedural
rules live — exit 8 means run verify exactly once and never resend, still_unknown ends
with a human rather than another attempt, exit 10 means show the user what is about to be
sent and get real consent before appending --yes, and a failed collection is never
paraphrased as an empty queue. The references/ files go deeper on scanning, bundles,
replies, and the error taxonomy, and are loaded only when the task calls for them.
If you are not driving this from an agent, ignore the directory entirely — nothing in the CLI depends on it.
The tool is v0.x, and the version number is honest: parts of the surface will still change. The split is deliberate, because agents branch on some of it and merely read the rest.
Treated as settled, and will not be broken:
- The shape of the JSON envelope —
{ok, data}and{ok, error:{type, message, hint, detail}}. - The exit-code table: the numeric values and what each one means.
- The
error.typetaxonomy names.
These three are what a caller writes control flow against, so they are held stable regardless of the leading zero in the version number.
Still open to breaking changes during v0.x:
- Fields inside
data, added or removed. - The
manifest.jsonschema. - The directory file schema.
- Flag defaults.
- Partitioning algorithm details.
Adding a field is not a breaking change. Consumers must tolerate unknown fields.
docs/CONTRACT.md— the authoritative contract: every command, the envelope, the error taxonomy, the collection algorithm, the manifest schema.docs/directory.example.yaml— directory file format.skills/freshdesk/— the agent skill pack.- SECURITY.md — vulnerability reporting and scope.