OptionalSoftware DevelopmentVersion 1.2.0

Debug REST and GraphQL APIs with Hermes Agent

Debug REST/GraphQL APIs: status codes, auth, schemas, repro.

Written by Neura Market from the official Hermes Agent documentation for Rest Graphql Debug. Commands, paths, and version numbers are reproduced from the source unchanged.

Read the official documentation

When an API call returns an unexpected status or a payload that breaks your code, the fastest path to a fix is isolating which layer failed. This skill gives you a repeatable, layered workflow for REST and GraphQL debugging using Hermes Agent's built-in tools: terminal for curl, execute_code for Python requests, and web_extract for pulling vendor documentation. You'll learn to check connectivity, timeouts, TLS, auth, request format, response parsing, and semantics in order, so you never guess at the fix again.

What it does

This skill turns API debugging from a chaotic hunt into a disciplined sequence. It walks you through each layer of the request/response chain, from "can we reach the host?" to "does the data mean what we assume?" You'll use curl for quick checks and Python requests for multi-step flows, with a clear playbook for common HTTP status codes, pagination, idempotency, contract validation, and correlation IDs. The goal is to isolate the failing layer before you change any code.

Before you start

This is an optional skill, installed on demand. It requires Hermes Agent with the terminal and execute_code tools available, plus network access to the API you're debugging. The skill works on Linux, macOS, and Windows. You'll want a valid API token or credentials ready, and you should be comfortable running shell commands and reading Python output. No additional packages are required beyond what Hermes provides.

The core principle: isolate, then fix

A 200 OK can hide broken data. A 500 can mask a one-character auth typo. The skill's core principle is to walk the chain in order and never skip a step. Here's the checklist you'll follow:

1. Connectivity   → can we reach the host at all?
1.5 Timeouts      → connect-slow vs read-slow?
2. TLS/SSL        → cert valid and trusted?
3. Auth           → credentials correct and unexpired?
4. Request format → payload shape match server expectations?
5. Response parse → does our code accept what came back?
6. Semantics      → does the data mean what we assume?

5-Minute Quickstart

REST via terminal

Start with a verbose curl to see the full exchange, including headers and any error output. The -v flag shows request and response headers, which is where you'll spot auth issues or content-type mismatches. The -sI variant fetches headers only, useful for a quick health check. Piping through python -m json.tool makes a large JSON response readable.

# Verbose request/response exchange
terminal('curl -v https://api.example.com/users/1')

# POST with JSON
terminal("""curl -X POST https://api.example.com/users \\
  -H 'Content-Type: application/json' \\
  -H "Authorization: Bearer $TOKEN" \\
  -d '{"name":"test","email":"test@example.com"}'""")

# Headers only
terminal('curl -sI https://api.example.com/health')

# Pretty-print JSON
terminal('curl -s https://api.example.com/users | python -m json.tool')

GraphQL via terminal

GraphQL uses a single POST endpoint with a JSON body containing the query:

terminal("""curl -X POST https://api.example.com/graphql \\
  -H 'Content-Type: application/json' \\
  -H "Authorization: Bearer $TOKEN" \\
  -d '{"query":"{ user(id: 1) { name email } }"}'""")

GraphQL gotcha: servers often return HTTP 200 even when the query failed. Always inspect the errors field regardless of status code. Here's a Python snippet that checks for GraphQL errors explicitly:

execute_code('''
import os, requests
resp = requests.post(
    "https://api.example.com/graphql",
    json={"query": "{ user(id: 1) { name email } }"},
    headers={"Authorization": f"Bearer {os.environ['TOKEN']}"},
    timeout=10,
)
data = resp.json()
if data.get("errors"):
    for err in data["errors"]:
        print(f"GraphQL error: {err['message']} (path: {err.get('path')})")
print(data.get("data"))
''')

Python (requests) via execute_code

For more control, use Python's requests library. Note the tuple timeout: (connect, read) separates the two phases:

execute_code('''
import requests
resp = requests.get(
    "https://api.example.com/users/1",
    headers={"Authorization": "Bearer <TOKEN>"},
    timeout=(3.05, 30),  # (connect, read)
)
print(resp.status_code, dict(resp.headers))
print(resp.text[:500])
''')

Layered Debug Flow

Step 1, Connectivity

First, confirm the host is reachable at all. DNS resolution and a quick connection test:

terminal('nslookup api.example.com')
terminal('curl -v --connect-timeout 5 https://api.example.com/health')

Failures here point to DNS not resolving, a firewall blocking the port, a required VPN, or a missing proxy.

Step 1.5, Timeouts

Distinguish can't reach from reaches but slow. Use curl's timing variables to see where time goes:

terminal('''curl -w "dns:%{time_namelookup}s connect:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\\n" \\
  -o /dev/null -s https://api.example.com/endpoint''')

In Python, always pass a tuple timeout. requests has no default and will hang forever if you don't:

execute_code('''
import requests
from requests.exceptions import ConnectTimeout, ReadTimeout
try:
    requests.get(url, timeout=(3.05, 30))
except ConnectTimeout:
    print("Cannot reach host — DNS, firewall, VPN")
except ReadTimeout:
    print("Connected but server is slow")
''')

Diagnosis: high time_connect is network/firewall; high time_starttransfer with low time_connect is a slow server.

Step 2, TLS/SSL

Check the certificate chain and validity:

terminal('curl -vI https://api.example.com 2>&1 | grep -E "SSL|subject|expire|issuer"')

Failures include expired certs, self-signed certs, hostname mismatch, or a missing CA bundle. Use -k only for ad-hoc debug, never in code.

Step 3, Authentication

Verify the token works and check its expiry:

# Token validity check
terminal('curl -s -o /dev/null -w "%{http_code}\\n" -H "Authorization: Bearer $TOKEN" https://api.example.com/me')

# Decode JWT exp claim — handles base64url padding correctly
execute_code('''
import json, base64, os
tok = os.environ["TOKEN"]
payload = tok.split(".")[1]
payload += "=" * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
''')

Checklist:

  • Token expired? (exp claim in JWT)
  • Right scheme? Bearer vs Basic vs Token vs X-Api-Key
  • Right environment? Staging key on prod is a classic
  • API key in header vs query param (?api_key=…)?

Step 4, Request Format

Send a verbose POST to see exactly what the server receives:

terminal("""curl -v -X POST https://api.example.com/endpoint \\
  -H 'Content-Type: application/json' \\
  -d '{"key":"value"}' 2>&1""")

Content-Type / body mismatch, the silent 415/400:

# WRONG — data= sends form-encoded, header lies
requests.post(url, data='{"k":"v"}', headers={"Content-Type": "application/json"})

# RIGHT — json= auto-sets header AND serializes
requests.post(url, json={"k": "v"})

# WRONG — Accept says XML, code calls .json()
requests.get(url, headers={"Accept": "text/xml"})

# RIGHT — let requests build multipart with boundary
requests.post(url, files={"file": open("doc.pdf", "rb")})

Common issues: form-encoded vs JSON, missing required fields, wrong HTTP method, unencoded query params.

Step 5, Response Parsing

Always inspect content-type before calling .json():

execute_code('''
import requests
resp = requests.post(url, json=payload, timeout=10)
print(f"status={resp.status_code}")
print(f"headers={dict(resp.headers)}")
ct = resp.headers.get("Content-Type", "")
if "application/json" in ct:
    print(resp.json())
else:
    print(f"unexpected content-type {ct!r}, body={resp.text[:500]!r}")
''')

Failures: HTML error page where JSON expected, empty body, wrong charset.

Step 6, Semantic Validation

Parsed cleanly, but is the data correct? Ask yourself:

  • Does "status": "active" mean what your code thinks?
  • ID in response matches the one requested?
  • Timestamps in expected timezone?
  • Pagination returning all results, or just page 1?

HTTP Status Playbook

401 Unauthorized, credentials missing or invalid

  1. Authorization header actually present? (curl -v to confirm)
  2. Token correct and unexpired?
  3. Right auth scheme? (Bearer vs Basic vs Token)
  4. Some APIs use query param (?api_key=…) instead of header.

403 Forbidden, authenticated but not authorized

  1. Token has the required scopes/permissions?
  2. Resource owned by a different account?
  3. IP allowlist blocking you?
  4. CORS in browser? (check Access-Control-Allow-Origin)

404 Not Found, resource doesn't exist or URL is wrong

  1. Path correct? (trailing slash, typo, version prefix)
  2. Resource ID exists?
  3. Right API version (/v1/ vs /v2/)?
  4. Right base URL (staging vs prod)?

409 Conflict, state collision

  1. Resource already exists (duplicate create)?
  2. Stale ETag / If-Match?
  3. Concurrent modification by another process?

422 Unprocessable Entity, valid JSON, invalid data

The error body usually names the bad fields. Check:

  • Field types (string vs int, date format)
  • Required vs optional
  • Enum values inside the allowed set

429 Too Many Requests, rate limited

Check Retry-After and X-RateLimit-* headers. Exponential backoff:

execute_code('''
import time, requests

def with_backoff(method, url, **kwargs):
    for attempt in range(5):
        resp = requests.request(method, url, **kwargs)
        if resp.status_code != 429:
            return resp
        wait = int(resp.headers.get("Retry-After", 2 ** attempt))
        time.sleep(wait)
    return resp
''')

5xx, server-side, usually not your fault

  • 500, server bug. Capture correlation ID, file with provider.
  • 502, upstream down. Backoff + retry.
  • 503, overloaded / maintenance. Check status page.
  • 504, upstream timeout. Reduce payload or raise timeout.

For all 5xx: backoff with jitter, alert on persistence.

Pagination & Idempotency

Pagination. Verify you're getting all results. Look for next_cursor, next_page, total_count. Two patterns:

  • Offset (?limit=100&offset=200), simple, can skip items if data shifts.
  • Cursor (?cursor=abc123), preferred for live or large datasets.

Idempotency. For non-idempotent operations (POST), send Idempotency-Key: so retries don't double-charge / double-create. Mandatory for payments and orders.

Contract Validation

Catch schema drift before it hits production:

execute_code('''
import requests

def validate_user(data: dict) -> list[str]:
    errors = []
    required = {"id": int, "email": str, "created_at": str}
    for field, expected in required.items():
        if field not in data:
            errors.append(f"missing field: {field}")
        elif not isinstance(data[field], expected):
            errors.append(f"{field}: want {expected.__name__}, got {type(data[field]).__name__}")
    return errors

resp = requests.get(f"{BASE}/users/1", headers=HEADERS, timeout=10)
issues = validate_user(resp.json())
if issues:
    print(f"contract violations: {issues}")
''')

Run after API upgrades, when integrating new third parties, or in CI smoke tests.

Correlation IDs

Always capture the provider's request ID, fastest path to vendor support:

execute_code('''
import requests
resp = requests.post(url, json=payload, headers=headers, timeout=10)
request_id = (
    resp.headers.get("X-Request-Id")
    or resp.headers.get("X-Trace-Id")
    or resp.headers.get("CF-Ray")  # Cloudflare
)
if resp.status_code >= 400:
    print(f"failed status={resp.status_code} req_id={request_id} ts={resp.headers.get('Date')}")
''')

Vendor bug-report template:

Endpoint:    POST /api/v1/orders
Request ID:  req_abc123xyz
Timestamp:   2026-03-17T14:30:00Z
Status:      500
Expected:    201 with order object
Actual:      500 {"error":"internal server error"}
Repro:       curl -X POST … (auth: <REDACTED>)

Regression Test Template

Drop this into tests/ and run via terminal('pytest tests/test_api_smoke.py -v'):

import os, requests, pytest

BASE_URL = os.environ.get("API_BASE_URL", "https://api.example.com")
TOKEN    = os.environ.get("API_TOKEN", "")
HEADERS  = {"Authorization": f"Bearer {TOKEN}"}

class TestAPISmoke:
    def test_health(self):
        resp = requests.get(f"{BASE_URL}/health", timeout=5)
        assert resp.status_code == 200

    def test_list_users_returns_array(self):
        resp = requests.get(f"{BASE_URL}/users", headers=HEADERS, timeout=10)
        assert resp.status_code == 200
        data = resp.json()
        assert isinstance(data.get("data", data), list)

    def test_get_user_required_fields(self):
        resp = requests.get(f"{BASE_URL}/users/1", headers=HEADERS, timeout=10)
        assert resp.status_code in (200, 404)
        if resp.status_code == 200:
            user = resp.json()
            assert "id" in user and "email" in user

    def test_invalid_auth_returns_401(self):
        resp = requests.get(
            f"{BASE_URL}/users",
            headers={"Authorization": "Bearer invalid-token"},
            timeout=10,
        )
        assert resp.status_code == 401

Security

Token handling

  • Never log full tokens. Redact: Bearer .
  • Never hardcode tokens in scripts. Read from env (os.environ["API_TOKEN"]) or ${HERMES_HOME:-~/.hermes}/.env.
  • Rotate immediately if a token surfaces in logs, error messages, or git history.

Safe logging

def redact_auth(headers: dict) -> dict:
    sensitive = {"authorization", "x-api-key", "cookie", "set-cookie"}
    return {k: ("<REDACTED>" if k.lower() in sensitive else v) for k, v in headers.items()}

Leak checklist

  • Credentials in URLs. API keys in query strings end up in server logs, browser history, referrer headers, use headers.
  • PII in error responses. 404 on /users/123 shouldn't reveal whether the user exists (enumeration).
  • Stack traces in prod. 500s shouldn't leak file paths, framework versions.
  • Internal hostnames/IPs. 10.x.x.x, internal-api.corp.local in error bodies.
  • Tokens echoed back. Some APIs include the auth token in error details. Verify they don't.
  • Verbose Server / X-Powered-By. Stack-info leaks. Note for security review.

Hermes Tool Patterns

terminal, for curl, dig, openssl

terminal('curl -sI https://api.example.com')
terminal('openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates')

execute_code, for multi-step Python flows

When debugging spans auth → fetch → paginate → validate, use execute_code. Variables persist for the script, results print to stdout, no risk of token spam in your context:

execute_code('''
import os, requests

token = os.environ["API_TOKEN"]
base  = "https://api.example.com"
H     = {"Authorization": f"Bearer {token}"}

# 1. auth
me = requests.get(f"{base}/me", headers=H, timeout=10)
print(f"auth {me.status_code}")

# 2. paginate
all_users, cursor = [], None
while True:
    params = {"cursor": cursor} if cursor else {}
    r = requests.get(f"{base}/users", headers=H, params=params, timeout=10)
    body = r.json()
    all_users.extend(body["data"])
    cursor = body.get("next_cursor")
    if not cursor:
        break
print(f"users={len(all_users)}")
''')

web_extract, for vendor API docs

Pull the spec for the endpoint you're debugging instead of guessing:

web_extract(urls=["https://docs.example.com/api/v1/users"])

delegate_task, for full CRUD test sweeps

delegate_task(
    goal="Test all CRUD endpoints for /api/v1/users",
    context="""
Follow the rest-graphql-debug skill (optional-skills/software-development/rest-graphql-debug).
Base URL: https://api.example.com
Auth: Bearer token from API_TOKEN env var.

For each verb (POST, GET, PATCH, DELETE):
  - happy path: assert status + response schema
  - error cases: 400, 404, 422
  - log a repro curl for any failure (redact tokens)

Output: pass/fail per endpoint + correlation IDs for failures.
""",
    toolsets=["terminal", "file"],
)

Output Format

When reporting findings, use this structure so the next person can reproduce and fix quickly:

## Finding
Endpoint: POST /api/v1/users
Status:   422 Unprocessable Entity
Req ID:   req_abc123xyz

## Repro
curl -X POST https://api.example.com/api/v1/users \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <REDACTED>' \
  -d '{"name":"test"}'

## Root Cause
Missing required field `email`. Server validation rejects before processing.

## Fix
-d '{"name":"test","email":"test@example.com"}'

When not to use it

This skill is for API-level debugging. Skip it for UI rendering issues, database query tuning, or DNS/firewall infrastructure problems. Those need different tools and escalation paths.

Limits and gotchas

  • GraphQL servers often return HTTP 200 even when the query failed. Always check the errors field.
  • Python's requests has no default timeout. Always pass a tuple (connect, read) to avoid hanging forever.
  • Using data= with a JSON string and a Content-Type: application/json header is a common mistake. Use json= instead.
  • Never use curl -k in production code. It's only for ad-hoc debugging.
  • Always redact tokens in logs and repro commands.

Related

  • systematic-debugging, once the failing API layer is isolated, root-cause your code
  • test-driven-development, write the regression test before shipping the fix

Skills the docs pair this with

More Software Development skills