Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

freshdesk-cli

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.

What it looks like

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 through

Note 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.

Why this exists

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 scan never 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.

Install

go install github.com/Dynmi/freshdesk-cli/cmd/freshdesk@latest

Requires 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.

Quick start

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 directory file

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.

The contract

Full details are in docs/CONTRACT.md, which is authoritative. This is the part an integrator has to know before writing any branching logic.

Envelope

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 codes

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.

Three invariants

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.

Security model

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 in ticket tag, ticket update and ticket close. There is no fifth. Private notes and deletion are absent, and ticket update is refused --status 5 so 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 send is not idempotent: its unknown outcome is resolved by one reply verify and never by resending.
  • The --yes gate comes first. Without --yes, reply send exits 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_duplicate with nothing sent. Only then the baseline recheck: the current ticket fingerprint is recomputed and compared to --baseline, and a mismatch means guard_evidence_changed with nothing sent. The order cannot be swapped — a duplicate reply necessarily bumps the latest conversation ID, so the baseline check would always trip first and guard_duplicate would 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. bundle downloads only from freshdesk.com, its subdomains, and s3.amazonaws.com, where Freshdesk's presigned attachments live. Anything else — third-party CDNs, tracking pixels in inbound email, cid: and data: URIs — is recorded in the ledger as skipped_policy with 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 no Authorization header.
  • 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.json holds 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.

The agent skill pack

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.

Stability

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.type taxonomy 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.json schema.
  • The directory file schema.
  • Flag defaults.
  • Partitioning algorithm details.

Adding a field is not a breaking change. Consumers must tolerate unknown fields.

Documentation

License

Apache-2.0.

About

freshdesk-cli

Resources

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages