Freshservice skill for Claude: tickets, assets, people, changes/problems/releases

Stdlib-only fs.py helper wrapping the Freshservice REST API v2 (auth, URL
building, filter quoting, pagination, error surfacing), plus SKILL.md and a
reference of endpoints, status/priority codes, and filter-query syntax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:14:13 -06:00
commit b66d72881d
5 changed files with 388 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.env
.DS_Store
# Credentials live in ~/.freshservice/credentials, never in the repo.

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# freshservice-claude
A [Claude](https://claude.com/claude-code) **skill** for working with
[Freshservice](https://www.freshservice.com/) ITSM from chat / Cowork — view,
create, update, reply to, and triage tickets; look up and update assets/CMDB;
find agents, requesters, groups, and departments; and manage changes, problems,
and releases via the Freshservice REST API v2.
## Layout
| File | Purpose |
|------|---------|
| `SKILL.md` | Skill entry point — setup, common commands, guardrails |
| `reference.md` | Endpoints, status/priority codes, filter-query syntax |
| `scripts/fs.py` | Stdlib-only API helper (auth, URL building, paging, errors) |
## Install
Copy the skill into your Claude skills directory:
```bash
cp -R . ~/.claude/skills/freshservice
```
## Configure credentials
```bash
mkdir -p ~/.freshservice
cat > ~/.freshservice/credentials <<'EOF'
FRESHSERVICE_DOMAIN=yourcompany
FRESHSERVICE_API_KEY=your_api_key
EOF
chmod 600 ~/.freshservice/credentials
python3 ~/.claude/skills/freshservice/scripts/fs.py check
```
The API key is in Freshservice → profile picture → *Profile settings* → API key.
`FRESHSERVICE_DOMAIN` accepts the subdomain (`yourcompany`) or the full host.
Environment variables of the same name override the file.
## Usage
The skill activates when you mention Freshservice or a ticket/asset/change in
chat. Under the hood it calls:
```bash
python3 scripts/fs.py request <METHOD> <PATH> [--param KEY=VALUE ...] [--data '<JSON>']
```
See `SKILL.md` and `reference.md` for details.

89
SKILL.md Normal file
View File

@@ -0,0 +1,89 @@
---
name: freshservice
description: Work with Freshservice ITSM from chat — view, create, update, reply to, and triage tickets; look up and update assets/CMDB; find agents, requesters, groups, and departments; and manage changes, problems, and releases. Use whenever the user mentions Freshservice, a ticket/incident/service request, an asset or CI, or a change/problem/release in their service desk.
---
# Freshservice
Interact with a Freshservice instance through its REST API v2 using the helper
script `scripts/fs.py`, which handles auth, base URL, query encoding, and
pagination.
## Setup (first run only)
The helper needs credentials. Check whether they exist:
```bash
python3 scripts/fs.py check
```
If it reports missing credentials, ask the user for their Freshservice
**domain** (subdomain like `acme`, or full host) and **API key** (found in
Freshservice → profile picture → *Profile settings* → API key). Then write:
```bash
mkdir -p ~/.freshservice
cat > ~/.freshservice/credentials <<'EOF'
FRESHSERVICE_DOMAIN=acme
FRESHSERVICE_API_KEY=PASTE_KEY_HERE
EOF
chmod 600 ~/.freshservice/credentials
```
Re-run `check` to confirm. (Environment variables `FRESHSERVICE_DOMAIN` /
`FRESHSERVICE_API_KEY` override the file if set.)
## How to call the API
One command does everything:
```bash
python3 scripts/fs.py request <METHOD> <PATH> [--param KEY=VALUE ...] [--data '<JSON>']
```
- `/api/v2` is added to PATH automatically.
- `--param query=...` and `--param filter=...` are auto-wrapped in the double
quotes the API requires — pass the expression unquoted.
- Output is pretty JSON with `status`, `data`, and `next_page` when more pages exist.
### Common operations
```bash
# Tickets
python3 scripts/fs.py request GET /tickets --param per_page=10 --param order_by=updated_at --param order_type=desc
python3 scripts/fs.py request GET /tickets/filter --param 'query=status:2 AND priority:3'
python3 scripts/fs.py request GET /tickets/123 --param include=conversations,requester
python3 scripts/fs.py request POST /tickets --data '{"subject":"Laptop won'\''t boot","description":"...","email":"user@co.com","priority":2,"status":2}'
python3 scripts/fs.py request PUT /tickets/123 --data '{"status":4}' # resolve
python3 scripts/fs.py request POST /tickets/123/reply --data '{"body":"Working on it."}'
python3 scripts/fs.py request POST /tickets/123/notes --data '{"body":"Internal note","private":true}'
# Assets / CMDB
python3 scripts/fs.py request GET /assets --param 'filter=name:'\''MacBook'\'''
python3 scripts/fs.py request GET /assets/45 --param include=type_fields
# People
python3 scripts/fs.py request GET /requesters --param email=user@co.com
python3 scripts/fs.py request GET /agents --param email=agent@co.com
python3 scripts/fs.py request GET /groups
python3 scripts/fs.py request GET /departments
# Change / Problem / Release
python3 scripts/fs.py request GET /changes/filter --param 'query=status:1'
python3 scripts/fs.py request GET /problems
python3 scripts/fs.py request GET /releases
```
For endpoints, field names, status/priority codes, and filter-query syntax, read
`reference.md`.
## Guidance
- **Confirm before writing.** Before creating/updating tickets/changes or posting
a public `reply` (the requester is emailed), show the user what you'll send and
get a yes — unless they've clearly already authorized it.
- Resolve people to ids first: look up a requester by email before assigning.
- Translate codes for the user (e.g. show "Open / High", not "status 2, priority 3").
- On a 4xx error, read the `error` body — it names the offending field — and fix
the request rather than retrying verbatim.
- Paginate when `next_page` appears and the user wants the full set.

75
reference.md Normal file
View File

@@ -0,0 +1,75 @@
# Freshservice API v2 reference
Base URL: `https://<domain>.freshservice.com/api/v2`. Auth is HTTP Basic with the
API key as username (handled by `scripts/fs.py`). Full docs: https://api.freshservice.com
## Codes
**Ticket status:** 2 Open · 3 Pending · 4 Resolved · 5 Closed
**Ticket priority:** 1 Low · 2 Medium · 3 High · 4 Urgent
**Ticket source:** 1 Email · 2 Portal · 3 Phone · 4 Chat · 5 Feedback widget ·
6 Yammer · 7 AWS CloudWatch · 8 PagerDuty · 9 Walkup · 10 Slack
Status/priority for changes use their own workflow codes; list a few existing
records to infer them, or read the ticket-fields / change-fields endpoints.
## Endpoints
| Resource | List | Create | View | Update | Delete |
|----------|------|--------|------|--------|--------|
| Tickets | `GET /tickets` | `POST /tickets` | `GET /tickets/{id}` | `PUT /tickets/{id}` | `DELETE /tickets/{id}` |
| Ticket filter | `GET /tickets/filter?query="..."` | — | — | — | — |
| Conversations | `GET /tickets/{id}/conversations` | reply: `POST /tickets/{id}/reply`; note: `POST /tickets/{id}/notes` | — | `PUT /conversations/{id}` | `DELETE /conversations/{id}` |
| Assets | `GET /assets` | `POST /assets` | `GET /assets/{display_id}` | `PUT /assets/{display_id}` | `DELETE /assets/{display_id}` |
| Agents | `GET /agents` | `POST /agents` | `GET /agents/{id}` | `PUT /agents/{id}` | — |
| Requesters | `GET /requesters` | `POST /requesters` | `GET /requesters/{id}` | `PUT /requesters/{id}` | — |
| Groups | `GET /groups` | `POST /groups` | `GET /groups/{id}` | `PUT /groups/{id}` | `DELETE /groups/{id}` |
| Departments | `GET /departments` | `POST /departments` | `GET /departments/{id}` | `PUT /departments/{id}` | `DELETE /departments/{id}` |
| Changes | `GET /changes` (+ `/changes/filter`) | `POST /changes` | `GET /changes/{id}` | `PUT /changes/{id}` | `DELETE /changes/{id}` |
| Problems | `GET /problems` | `POST /problems` | `GET /problems/{id}` | `PUT /problems/{id}` | `DELETE /problems/{id}` |
| Releases | `GET /releases` | `POST /releases` | `GET /releases/{id}` | `PUT /releases/{id}` | `DELETE /releases/{id}` |
| Current agent | `GET /agents/me` | — | — | — | — |
Other useful endpoints reachable the same way: `/solutions/categories`,
`/solutions/folders`, `/solutions/articles`, `/products`, `/vendors`,
`/ticket_fields`, `/tickets/{id}/time_entries`, `/canned_responses`.
## Ticket fields (create/update)
`subject`, `description` (HTML ok), `email` or `requester_id`, `priority`,
`status`, `source`, `group_id`, `responder_id` (assigned agent), `type`
(e.g. "Incident", "Service Request"), `tags` (array), `category`,
`sub_category`, `custom_fields` (object), `due_by`, `fr_due_by`.
Provide **either** `email` **or** `requester_id`. For a public reply the
requester is emailed; a note with `"private": true` is internal-only.
## Filter (query) syntax — /tickets/filter and /changes/filter
- Format: `field:value`, combined with `AND` / `OR`.
- Strings single-quoted: `tag:'vip'`. Numbers/dates bare or quoted.
- Operators on dates/numbers: `>`, `<`, e.g. `created_at:>'2026-01-01'`.
- Filterable ticket fields include: `status`, `priority`, `agent_id`,
`group_id`, `requester_id`, `type`, `source`, `tag`, `created_at`,
`updated_at`, `due_by`, plus custom fields by name.
- The whole expression must be URL-double-quoted; `scripts/fs.py` does this when
you pass `--param query=...` / `--param filter=...`.
Examples:
- `status:2 AND priority:3`
- `agent_id:0 AND status:2` — unassigned open tickets
- `type:'Incident' AND created_at:>'2026-06-01'`
## Listing vs. filtering
- `GET /tickets` supports `filter` presets (`new_and_my_open`, `watching`,
`spam`, `deleted`), `updated_since`, `order_by`, `order_type`, `page`,
`per_page` (max 100), and `include` (`conversations`, `requester`, `stats`).
- `GET /tickets/filter?query="..."` is for ad-hoc field queries (also 100/page).
## Pagination & rate limits
- 30 results/page default, 100 max via `per_page`. `scripts/fs.py` surfaces
`next_page` when a next-page Link header is present.
- Per-plan minute limits (100500/min). Responses include
`rate_limit_remaining`.

168
scripts/fs.py Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Freshservice API helper for the freshservice skill.
Handles auth, base URL, query encoding, JSON bodies, and pagination so the
model only has to choose an HTTP method, a path, and parameters.
Credentials (checked in this order):
1. Environment: FRESHSERVICE_DOMAIN, FRESHSERVICE_API_KEY
2. File ~/.freshservice/credentials with lines:
FRESHSERVICE_DOMAIN=acme
FRESHSERVICE_API_KEY=xxxxxxxx
FRESHSERVICE_DOMAIN may be the subdomain ("acme") or full host.
Usage:
fs.py check
fs.py request GET /tickets --param per_page=5
fs.py request GET /tickets/filter --param 'query=status:2 AND priority:3'
fs.py request GET /tickets/123 --param include=conversations
fs.py request POST /tickets --data '{"subject":"...","description":"...","email":"a@b.com","priority":2,"status":2}'
fs.py request PUT /tickets/123 --data '{"status":4}'
fs.py request POST /tickets/123/reply --data '{"body":"Hi"}'
Notes:
* "/api/v2" is prepended to PATH automatically if absent.
* For the filter endpoints the API requires the value wrapped in double
quotes (?query="..."). This helper auto-wraps the `query` and `filter`
params, so pass them unquoted.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
def load_credentials() -> tuple[str, str]:
domain = os.environ.get("FRESHSERVICE_DOMAIN", "").strip()
api_key = os.environ.get("FRESHSERVICE_API_KEY", "").strip()
cred_file = os.path.expanduser("~/.freshservice/credentials")
if (not domain or not api_key) and os.path.exists(cred_file):
with open(cred_file) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k == "FRESHSERVICE_DOMAIN" and not domain:
domain = v
elif k == "FRESHSERVICE_API_KEY" and not api_key:
api_key = v
if not domain or not api_key:
sys.exit(
"ERROR: Missing credentials. Set FRESHSERVICE_DOMAIN and "
"FRESHSERVICE_API_KEY in the environment or in "
"~/.freshservice/credentials (see the skill's SETUP section)."
)
if "." not in domain:
domain = f"{domain}.freshservice.com"
return domain, api_key
def request(method: str, path: str, params: dict[str, str], data: str | None) -> dict:
domain, api_key = load_credentials()
path = path if path.startswith("/") else f"/{path}"
if not path.startswith("/api/"):
path = f"/api/v2{path}"
url = f"https://{domain}{path}"
# The filter endpoints require the value double-quoted in the URL.
encoded = {}
for k, v in params.items():
if k in ("query", "filter") and not (v.startswith('"') and v.endswith('"')):
v = f'"{v}"'
encoded[k] = v
if encoded:
url += "?" + urllib.parse.urlencode(encoded)
token = base64.b64encode(f"{api_key}:X".encode()).decode()
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
body = data.encode() if data else None
req = urllib.request.Request(url, data=body, method=method.upper(), headers=headers)
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read().decode()
out = {"ok": True, "status": resp.status,
"data": json.loads(raw) if raw else {}}
link = resp.headers.get("Link")
if link:
m = re.search(r'page=(\d+)[^>]*>;\s*rel="next"', link)
if m:
out["next_page"] = int(m.group(1))
rem = resp.headers.get("X-RateLimit-Remaining")
if rem is not None:
out["rate_limit_remaining"] = rem
return out
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
try:
detail = json.loads(detail)
except json.JSONDecodeError:
pass
return {"ok": False, "status": e.code, "error": detail,
"method": method.upper(), "url": url}
except urllib.error.URLError as e:
return {"ok": False, "error": f"Network error: {e.reason}", "url": url}
def main() -> None:
p = argparse.ArgumentParser(description="Freshservice API helper")
sub = p.add_subparsers(dest="cmd", required=True)
pr = sub.add_parser("request", help="Call any API v2 endpoint")
pr.add_argument("method", help="GET, POST, PUT, DELETE")
pr.add_argument("path", help='API path, e.g. "/tickets" or "/tickets/123"')
pr.add_argument("--param", action="append", default=[],
metavar="KEY=VALUE", help="Query parameter (repeatable)")
pr.add_argument("--data", help="JSON request body for POST/PUT")
sub.add_parser("check", help="Verify credentials and connectivity")
args = p.parse_args()
if args.cmd == "check":
res = request("GET", "/agents/me", {}, None)
if res.get("ok"):
who = res["data"].get("agent", {})
print(json.dumps({"ok": True,
"connected_as": who.get("email") or who.get("first_name"),
"status": res["status"]}, indent=2))
else:
print(json.dumps(res, indent=2, default=str))
sys.exit(1)
return
params = {}
for item in args.param:
if "=" not in item:
sys.exit(f"ERROR: --param must be KEY=VALUE, got: {item}")
k, v = item.split("=", 1)
params[k] = v
if args.data:
try:
json.loads(args.data)
except json.JSONDecodeError as e:
sys.exit(f"ERROR: --data is not valid JSON: {e}")
res = request(args.method, args.path, params, args.data)
print(json.dumps(res, indent=2, default=str))
if not res.get("ok"):
sys.exit(1)
if __name__ == "__main__":
main()