Skill: eisimo/agentic-letters (version 1.1.2)

--- name: agentic-letters description: "Send physical letters anywhere in Germany with a single command. Use when: user wants to send a physical letter. Requires an API key from agentic-letters.com." homepage: https://agentic-letters.com metadata: { "openclaw": { "emoji": "✉️", "requires": { "bins": ["python3"] }, "primaryEnv": "AGENTIC_LETTERS_API_KEY", }, } --- # AgenticLetters Send physical letters anywhere in Germany via the [agentic-letters.com](https://agentic-letters.com) API. Your PDF gets printed, put in an envelope, and mailed — one API call, one real letter. ## When to use - User wants to send a physical letter, cancellation, or legal notice - User needs to mail a document (PDF) to a German address - User says "send a letter", "Kündigung schicken", "Brief versenden" or something similar - User wants to mail a DSGVO request, cancellation, complaint, or greeting card ## Setup ```bash mkdir -p ~/.openclaw/secrets echo [REDACTED_SECRET] > ~/.openclaw/secrets/agentic_letters.env ``` ## Getting an API key AgenticLetters has no account system and no login. The flow is: 1. Go to https://agentic-letters.com/buy 2. Enter an email address and pick a credit package 3. Pay via Stripe (card) 4. The API key (starts with `al_`) is sent to that email immediately The key is a Bearer token used in every request. If the user buys more credits later with the same email, the existing key gets topped up — no new key issued. If the user doesn't have a key yet, guide them to https://agentic-letters.com/buy. ## Tool `{baseDir}/agentic_letters.py` — a zero-dependency Python CLI (stdlib only). ## Send a letter 1. Generate an A4 PDF (max 3 pages, max 10 MB, black & white print) 2. Run the tool: ```bash python3 {baseDir}/agentic_letters.py send \ --pdf letter.pdf \ --name "Max Mustermann" \ --street "Musterstraße 1" \ --zip 10115 \ --city Berlin \ --label "Kündigung Fitnessstudio" ``` Optional flags: - `--type <type>` — letter type (default: `standard`). New types will be added over time; the API rejects unknown types with a list of valid ones. - `--country <code>` — country code (default: `DE`). Currently only Germany is supported. Output (JSON to stdout): ```json { "id": "550e8400-e29b-41d4-a716", "status": "queued", "type": "standard", "label": "Kündigung Fitnessstudio", "created_at": "2026-02-24T19:00:00Z", "credits_remaining": 4 } ``` ## Check letter status ```bash python3 {baseDir}/agentic_letters.py status <letter-id> ``` Status values: `queued` → `printed` → `sent` → `returned` ## Check remaining credits ```bash python3 {baseDir}/agentic_letters.py credits ``` ## List all letters ```bash python3 {baseDir}/agentic_letters.py list ``` ## Local records Every sent letter is tracked locally in `{baseDir}/records/`. Each file is named `YYYY-MM-DD_<id-prefix>.json` and contains: ```json { "id": "550e8400-...", "status": "queued", "type": "standard", "label": "Kündigung Fitnessstudio", "recipient": { "name": "Max Mustermann", "street": "Musterstraße 1", "zip": "10115", "city": "Berlin", "country": "DE" }, "created_at": "2026-02-24T19:00:00Z", "credits_remaining": 4, "last_checked": null } ``` Records are created automatically on `send` and updated on `status`. The date prefix lets agents quickly find recent letters without scanning old files. To check on pending letters, look at recent record files and call `status` for any that aren't `sent` yet. ## Generating PDFs If the user doesn't have a PDF ready, generate one: - `pandoc` for markdown → PDF: `echo "Dear Sir..." | pandoc -o letter.pdf` - `wkhtmltopdf` for HTML → PDF: `wkhtmltopdf letter.html letter.pdf` - Python with `fpdf2` or `reportlab` for programmatic generation Always ensure A4 format (210 × 297 mm) with at least 15 mm margins. ## Error handling Errors go to stderr with a clear origin tag. The exit code is non-zero on failure. **Origins:** - `[local]` — problem before the request (missing file, no API key) - `[server]` — the API rejected the request (includes error code, HTTP status, detail, and field) - `[network]` — could not reach the API (DNS, timeout, connection refused) Example server error: ``` [server] Invalid German postal code code: recipient_zip_invalid http_status: 400 detail: Expected a 5-digit German PLZ (e.g. "10115"), got "123". field: recipient.zip ``` On success, JSON is printed to stdout. On failure, nothing goes to stdout. ## Important constraints - **Germany only** — recipient must have a German address - **Max 3 pages** — longer PDFs are rejected by the server - **Max 10 MB** — compress images if needed - **Black & white** — images are printed in grayscale - **1 credit = 1 letter** — check credits before sending - **A4 format** — ensure correct page size - **Do not validate the PDF locally** — the server handles all PDF validation ## Typical workflows **Kündigung (cancellation):** Ask for: service name, customer number, recipient address. Generate a formal cancellation letter as PDF, send it. **DSGVO Auskunftsersuchen (data access request):** Ask for: company name, address, user's full name. Generate a DSGVO Art. 15 request letter, send it. **Widerspruch (objection/appeal):** Ask for: authority/company, reference number, reason. Generate a formal objection letter, send it.

path: _meta.json
content: ownerId: kn78zw663j7tx4fvrn3yt9h9v981v93q
slug: agentic-letters
version: 1.1.1
publishedAt: 1772034782711

path: agentic_letters.py
content: #!/usr/bin/env python3
"""AgenticLetters CLI — send physical letters via the AgenticLetters API.

Designed as an OpenClaw agent skill tool. All errors go to stderr with
structured context so the calling AI can distinguish local from server errors.
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import NoReturn


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

API_BASE = "https://agentic-letters.com/api"
ENV_FILE = os.path.expanduser("~/.openclaw/secrets/agentic_letters.env")
ENV_VAR = "AGENTIC_LETTERS_API_KEY"
RECORDS_DIR = os.path.expanduser("~/.openclaw/workspace/skills/agentic-letters/records")


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------

class ErrorOrigin(Enum):
    LOCAL = "local"
    SERVER = "server"
    NETWORK = "network"


@dataclass
class CLIError:
    origin: ErrorOrigin
    message: str
    code: str | None = None
    detail: str | None = None
    field: str | None = None
    http_status: int | None = None

    def format(self) -> str:
        parts = [f"[{self.origin.value}] {self.message}"]
        if self.code:
            parts.append(f"  code: {self.code}")
        if self.http_status:
            parts.append(f"  http_status: {self.http_status}")
        if self.detail:
            parts.append(f"  detail: {self.detail}")
        if self.field:
            parts.append(f"  field: {self.field}")
        return "\n".join(parts)


def die(err: CLIError) -> NoReturn:
    print(err.format(), file=sys.stderr)
    sys.exit(1)


def die_local(message: str, *, detail: str | None = None) -> NoReturn:
    die(CLIError(origin=ErrorOrigin.LOCAL, message=message, detail=detail))


# ---------------------------------------------------------------------------
# API client
# ---------------------------------------------------------------------------

class AgenticLettersClient:
    """Thin HTTP client for the AgenticLetters API (stdlib only)."""

    def __init__(self, api_key: str):
        self._api_key = api_key

    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self._api_key}",
            "Content-Type": "application/json",
            "User-Agent": "agentic-letters-skill/1.0",
        }

    def _request(self, method: str, path: str, body: dict | None = None) -> dict:
        url = f"{API_BASE}{path}"
        data = json.dumps(body).encode() if body else None
        req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)

        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as e:
            try:
                error_body = json.loads(e.read())
            except Exception:
                die(CLIError(
                    origin=ErrorOrigin.SERVER,
                    message=f"HTTP {e.code} with non-JSON response",
                    http_status=e.code,
                    detail=str(e.reason),
                ))
            die(CLIError(
                origin=ErrorOrigin.SERVER,
                message=error_body.get("error", f"HTTP {e.code}"),
                code=error_body.get("code"),
                http_status=e.code,
                detail=error_body.get("detail"),
                field=error_body.get("field"),
            ))
        except urllib.error.URLError as e:
            die(CLIError(
                origin=ErrorOrigin.NETWORK,
                message="Could not reach the API",
                detail=str(e.reason),
            ))
        except TimeoutError:
            die(CLIError(
                origin=ErrorOrigin.NETWORK,
                message="Request timed out after 60 seconds",
            ))

    # -- Public methods --

    def send_letter(
        self,
        pdf_path: str,
        name: str,
        street: str,
        zip_code: str,
        city: str,
        *,
        country: str = "DE",
        letter_type: str = "standard",
        label: str | None = None,
    ) -> dict:
        path = Path(pdf_path)
        if not path.exists():
            die_local(f"File not found: {pdf_path}")
        if not path.is_file():
            die_local(f"Not a file: {pdf_path}")

        try:
            pdf_bytes = path.read_bytes()
        except PermissionError:
            die_local(f"Permission denied: {pdf_path}")
        except OSError as e:
            die_local(f"Cannot read file: {pdf_path}", detail=str(e))

        pdf_b64 = base64.b64encode(pdf_bytes).decode()

        payload: dict = {
            "pdf": pdf_b64,
            "recipient": {
                "name": name,
                "street": street,
                "zip": zip_code,
                "city": city,
                "country": country,
            },
            "type": letter_type,
        }
        if label:
            payload["label"] = label

        return self._request("POST", "/letters", payload)

    def get_letter(self, letter_id: str) -> dict:
        return self._request("GET", f"/letters/{letter_id}")

    def list_letters(self) -> dict:
        return self._request("GET", "/letters")

    def get_credits(self) -> dict:
        return self._request("GET", "/credits")


# ---------------------------------------------------------------------------
# Local letter records
# ---------------------------------------------------------------------------

def _ensure_records_dir() -> Path:
    p = Path(RECORDS_DIR)
    p.mkdir(parents=True, exist_ok=True)
    return p


def save_record(send_result: dict, recipient: dict, label: str | None) -> Path:
    """Save a letter record after successful send."""
    records = _ensure_records_dir()
    letter_id = send_result["id"]
    date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    record = {
        "id": letter_id,
        "status": send_result.get("status", "queued"),
        "type": send_result.get("type", "standard"),
        "label": label,
        "recipient": recipient,
        "created_at": send_result.get("created_at", datetime.now(timezone.utc).isoformat()),
        "credits_remaining": send_result.get("credits_remaining"),
        "last_checked": None,
    }
    path = records / f"{date}_{letter_id[:8]}.json"
    path.write_text(json.dumps(record, indent=2, ensure_ascii=False))
    return path


def update_record_status(letter_id: str, status_result: dict) -> None:
    """Update an existing record with fresh status from the API."""
    records = _ensure_records_dir()
    # Find record by ID prefix
    for f in sorted(records.iterdir(), reverse=True):
        if not f.name.endswith(".json"):
            continue
        try:
            data = json.loads(f.read_text())
        except Exception:
            continue
        if data.get("id", "").startswith(letter_id) or letter_id.startswith(data.get("id", "")[:8]):
            data["status"] = status_result.get("status", data["status"])
            data["last_checked"] = datetime.now(timezone.utc).isoformat()
            f.write_text(json.dumps(data, indent=2, ensure_ascii=False))
            return


# ---------------------------------------------------------------------------
# API key resolution
# ---------------------------------------------------------------------------

def load_api_key() -> str:
    """Load API key from environment or secrets file."""
    key = os.environ.get(ENV_VAR)
    if key:
        return key.strip()

    env_path = Path(ENV_FILE)
    if env_path.exists():
        for line in env_path.read_text().splitlines():
            line = line.strip()
            if line.startswith(f"{ENV_VAR}="):
                val = line.split("=", 1)[1].strip().strip('"').strip("'")
                if val:
                    return val

    die_local(
        "No API key found",
        detail=f"Set {ENV_VAR} in environment or in {ENV_FILE}. Get a key at https://agentic-letters.com/buy",
    )


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="agentic_letters",
        description="Send physical letters via the AgenticLetters API.",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # send
    send_p = sub.add_parser("send", help="Send a letter")
    send_p.add_argument("--pdf", required=True, help="Path to the PDF file")
    send_p.add_argument("--name", required=True, help="Recipient full name")
    send_p.add_argument("--street", required=True, help="Recipient street + number")
    send_p.add_argument("--zip", required=True, help="Recipient postal code (5-digit German PLZ)")
    send_p.add_argument("--city", required=True, help="Recipient city")
    send_p.add_argument("--country", default="DE", help="Recipient country code (default: DE)")
    send_p.add_argument("--type", default="standard", dest="letter_type", help="Letter type (default: standard)")
    send_p.add_argument("--label", help="Optional label for your reference")

    # status
    status_p = sub.add_parser("status", help="Check letter status")
    status_p.add_argument("id", help="Letter UUID")

    # list
    sub.add_parser("list", help="List all letters")

    # credits
    sub.add_parser("credits", help="Check remaining credits")

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    [REDACTED_SECRET])
    client = AgenticLettersClient(api_key)

    if args.command == "send":
        result = client.send_letter(
            pdf_path=args.pdf,
            name=args.name,
            street=args.street,
            zip_code=args.zip,
            city=args.city,
            country=args.country,
            letter_type=args.letter_type,
            label=args.label,
        )
        # Save local record
        recipient = {
            "name": args.name,
            "street": args.street,
            "zip": args.zip,
            "city": args.city,
            "country": args.country,
        }
        save_record(result, recipient, args.label)
    elif args.command == "status":
        result = client.get_letter(args.id)
        update_record_status(args.id, result)
    elif args.command == "list":
        result = client.list_letters()
    elif args.command == "credits":
        result = client.get_credits()
    else:
        parser.print_help()
        sys.exit(1)

    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)