forked from spencerm/freshservice-claude
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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
# Credentials live in ~/.freshservice/credentials, never in the repo.
|
||||||
51
README.md
Normal file
51
README.md
Normal 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
89
SKILL.md
Normal 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
75
reference.md
Normal 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 (100–500/min). Responses include
|
||||||
|
`rate_limit_remaining`.
|
||||||
168
scripts/fs.py
Normal file
168
scripts/fs.py
Normal 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()
|
||||||
Reference in New Issue
Block a user