---
name: self-hosted-api-clients
title: Self-Hosted API Client Wrappers
category: devops
description: Building custom CLI wrappers for personal apps and web services on headless servers using only Python stdlib. Covers REST APIs, local endpoints, data sync, and integration with user's self-hosted infrastructure.
---

# Self-Hosted API Client Wrappers

## When to use this skill
- The app/service runs on the user's own server (Docker, PWA, custom HTTP endpoint).
- No official CLI / SDK exists or it's too heavy.
- `pip install requests` is not available (restricted server).
- The API is simple JSON over HTTP — perfect for `urllib` + JSON.

## Core principle
Write a **single-file executable Python script** (no dependencies) that:
1. Reads/writes data from a local HTTP API.
2. Accepts CLI arguments or interactive prompts.
3. Saves securely (never logs secrets).

## Pattern: `urllib`-only REST client

```python
#!/usr/bin/env python3
import urllib.request, json, sys

API = "http://localhost:PORT/api/path"

def get():
    req = urllib.request.Request(API)
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

def post(data):
    payload = json.dumps(data).encode()
    req = urllib.request.Request(API, data=payload, headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req) as r:
        return r.status == 200
```

## Pattern: Interactive CLI with numbered categories
When the user can't remember category names, show a numbered list:

```python
CATS = [
    ("Schlafen", "😴"), ("Lernen", "📚"), ("Uni", "🏫"),
    ("Work", "💼"), ("Islam", "🕌"),
]

def show_cats():
    for i, (cat, icon) in enumerate(CATS, 1):
        print(f"  {i}. {icon} {cat}")
```

Parse input like `"4 2h, 3 30min"`:
```python
import re

def parse_time(tok):
    m = re.match(r'(\d+(?:\.\d+)?)\s*(?:h|stunden?)', tok, re.I)
    if m: return round(float(m.group(1)), 2)
    m = re.match(r'(\d+)\s*(?:min|m)', tok, re.I)
    if m: return round(int(m.group(1)) / 60, 2)
    return None
```

## Pattern: Merge-or-append on the server
Read existing → modify list → write back, so multiple entries per day accumulate:

```python
existing = load_from_server(key)
found = next((e for e in existing if e["category"] == cat), None)
if found:
    found["hours"] = round(found["hours"] + new_hours, 2)
else:
    existing.append({"category": cat, "hours": new_hours})
post({key: json.dumps(existing)})
```

## Typical use cases
| App | What we built | API |
|-----|--------------|-----|
| **Google Calendar** (no pip) | `kiwitime` | `calendar.googleapis.com` |
| **DailyDose** (PWA, local) | `dosed` | `localhost:8765/api/data` |
| **CasaOS apps** | health check | `localhost:PORT` or Docker socket |
| **Notion** (future) | `noted` | `api.notion.com` via curl |
| **Personal vault / wiki** | `vaultcli` | Custom backend |

## Installation
Save to `/DATA/AppData/hermes/.bin/<name>`, make executable:
```bash
chmod +x /DATA/AppData/hermes/.bin/<name>
```

No venv, no pip, no `requirements.txt`.

## Pitfalls
- `urllib.request.Request` does **not** accept `timeout=` as a keyword in some Python 3.12 versions — use `urllib.request.urlopen(req, timeout=N)` instead.
- Always wrap `urlopen` in `try/except urllib.error.HTTPError` for 4xx/5xx.
- `json.dumps()` produces ASCII-escaped unicode for non-ASCII. Use `ensure_ascii=False` if the API expects raw UTF-8.
- Server may return bytes on error bodies — decode with `.decode(errors='ignore')` before printing.

## References
- `references/dosed-template.py` — The `dosed` DailyDose CLI as a clean reference
- `references/kiwitime-template.py` — The `kiwitime` Google Calendar CLI (OAuth + stdlib)
