Skill: rayhollister/buttondown (version 0.1.0)

--- name: buttondown description: Create, update, inspect, and send test previews of Buttondown newsletter emails through the Buttondown API. Use when Codex needs to post newsletter copy as a Buttondown draft, manage Buttondown email drafts, preview draft emails to specific recipients, or inspect Buttondown email metadata. Defaults to draft-only and requires explicit user approval before sending, scheduling, publishing, deleting, or emailing previews. metadata: openclaw: emoji: "✉️" homepage: https://forge.rayhollister.com/rayhollister/buttondown envVars: - name: BUTTONDOWN_API_KEY required: true description: Buttondown API key. Store it in your shell, launcher, runtime secret store, or OpenClaw SecretRef; never commit it. - name: BUTTONDOWN_CONTEXT required: false description: Optional Buttondown newsletter username for accounts that manage more than one newsletter. --- # Buttondown Use Buttondown's API to create and manage newsletter email drafts. ## Safety Defaults - Draft-first: when creating an email, set `status: draft` unless the user explicitly approves a different status in the current conversation. - Do not send, schedule, publish, delete, or send draft previews without explicit user approval in the current conversation. - Do not put API keys in chat, command history, repo files, draft bodies, logs, or screenshots. - Read the Buttondown API key from `BUTTONDOWN_API_KEY`. Store it in your shell, launcher, runtime secret store, or OpenClaw SecretRef; never ask the user to paste the token into chat. - If using a platform account for multiple newsletters, pass the Buttondown newsletter username through `--context` or `BUTTONDOWN_CONTEXT`. ## Configuration The bundled CLI reads the API key from: ```bash export BUTTONDOWN_API_KEY="..." ``` Prefer setting this through your runtime's secret manager or environment injection rather than typing it into a command. In OpenClaw, configure it through a SecretRef or equivalent managed environment variable. Optional environment: - `BUTTONDOWN_CONTEXT` - newsletter username for the `Buttondown-Context` header. - `BUTTONDOWN_API_BASE` - defaults to `https://api.buttondown.com/v1`. ## CLI Run commands from the skill directory: ```bash python3 scripts/buttondown.py create-draft --subject "Subject" --body-file issue.md python3 scripts/buttondown.py create-draft --subject "Subject" --body-file issue.md --slug "issue-1" --description "SEO/archive summary" python3 scripts/buttondown.py create-draft --subject "Subject" --body-file issue.md --editor-mode plaintext python3 scripts/buttondown.py update-draft <email_id> --subject "New subject" --body-file issue.md python3 scripts/buttondown.py list --status draft python3 scripts/buttondown.py get <email_id> python3 scripts/buttondown.py render <email_id> python3 scripts/buttondown.py send-draft <email_id> --recipient [REDACTED_SECRET] ``` Use `--dry-run` on write commands to inspect the request without calling Buttondown: ```bash python3 scripts/buttondown.py create-draft --subject "Subject" --body-file issue.md --dry-run ``` ## Workflow 1. Prepare clean Markdown or HTML body content in a local file. 2. Remove YAML frontmatter from the body before uploading. Buttondown rejects bodies that start with frontmatter. 3. Create the draft with `create-draft`; confirm the response contains `status: draft`. 4. Share the Buttondown email ID and archive/admin URL from the response when available. 5. For a preview email, show the user the target recipient(s) and get explicit approval, then run `send-draft`. ## Body Format Buttondown detects Markdown versus HTML automatically. To force the editor mode, pass: - `--editor-mode plaintext` for Markdown. - `--editor-mode fancy` for rich HTML. The CLI prepends the official Buttondown editor-mode comment when this option is set. ## API Reference Read `references/api.md` when you need endpoint details, request fields, status values, error codes, or direct curl equivalents.

path: agents/openai.yaml
content: interface:
  display_name: "Buttondown"
  short_description: "Create and manage Buttondown email drafts"
  default_prompt: "Use $buttondown to post this newsletter issue as a Buttondown draft."


path: references/api.md
content: # Buttondown API Reference

Source checked 2026-05-23 from Buttondown docs and `https://docs.buttondown.com/openapi.json`.

## Authentication

- Base URL: `https://api.buttondown.com/v1`.
- Header: `Authorization: Token <api-key>`.
- Optional platform/multi-newsletter header: `Buttondown-Context: <newsletter-username>`.
- API keys are managed in Buttondown at API -> Keys.
- For creating and updating drafts, the key needs `email_access=write`.
- For sending previews or live sends, the key needs the relevant sending access. Do not use sending actions without explicit approval.

## Create Email

`POST /emails`

Important default: Buttondown's `EmailInput.status` defaults to `about_to_send`. Always send `"status":"draft"` for draft creation unless the user explicitly approves sending/scheduling.

Required:

- `subject` string, max 2000 chars.

Common fields:

- `body` string, Markdown or HTML.
- `status` enum: `draft`, `about_to_send`, `scheduled`, `sent`, plus other system states.
- `slug` string or null, max 100 chars.
- `description` string for archives/SEO.
- `canonical_url` string.
- `image` string URL.
- `publish_date` ISO date-time or null.
- `archival_mode`: `disabled`, `enabled`, `enabled_for_paid_subscribers`, `enabled_for_subscribers`.
- `commenting_mode`: `disabled`, `enabled`, `enabled_for_paid_subscribers`.
- `metadata` object.
- `secondary_id` integer.
- `featured` boolean.
- `related_email_ids` array of IDs.

Body mode:

- Markdown/plaintext: prepend `<!-- buttondown-editor-mode: plaintext -->`.
- Rich HTML/fancy: prepend `<!-- buttondown-editor-mode: fancy -->`.
- If the body starts with YAML frontmatter, Buttondown rejects it with `400` and `body_contains_frontmatter`. Strip frontmatter before upload.

Draft curl shape:

```bash
jq -n --arg subject "$SUBJECT" --rawfile body issue.md '{subject:$subject, body:$body, status:"draft"}' \
  | curl -fsS -X POST "https://api.buttondown.com/v1/emails" \
      -H "Authorization: Token $BUTTONDOWN_API_KEY" \
      -H "Content-Type: application/json" \
      --data-binary @-
```

## Update Email

`PATCH /emails/{id}`

Use for draft edits. Same common fields as create. To clear a scheduled `publish_date`, pass `"publish_date":"none"`.

## Retrieve/List

- `GET /emails/{id}` retrieves one email.
- `GET /emails` lists emails.
- `GET /emails/{id}/renders` returns rendered HTML.
- `GET /emails/{id}/history` retrieves edit history.

## Send Draft Preview

`POST /emails/{id}/send-draft`

Payload:

```json
{
  "recipients": ["[REDACTED_SECRET]"],
  "subscribers": ["subscriber_id"]
}
```

This sends email externally. Get explicit user approval in the current conversation before using it.

## Error Codes To Surface

Creation errors include:

- `body_contains_frontmatter`
- `email_duplicate`
- `email_not_confirmed`
- `body_invalid`
- `canonical_url_invalid`
- `email_invalid`
- `slug_invalid`
- `subject_invalid`
- `publish_date_invalid`
- `publish_date_missing`
- `sending_requires_confirmation`
- `status_invalid`
- `tag_invalid`

When Buttondown returns JSON with `detail`, report that text directly and keep token values redacted.


path: scripts/buttondown.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request


DEFAULT_API_BASE = "https://api.buttondown.com/v1"


def die(message, code=2):
    print(f"Error: {message}", file=sys.stderr)
    raise SystemExit(code)


def get_api_key(required=True):
    [REDACTED_SECRET]"BUTTONDOWN_API_KEY", "").strip()
    if [REDACTED_SECRET] api_key
    if required:
        die("set BUTTONDOWN_API_KEY in the environment or configure it through your runtime secret store")
    return ""


def redact(text, api_key):
    if api_key:
        text = text.replace(api_key, "[REDACTED]")
    return text


def read_body(path, editor_mode):
    with open(path, "r", encoding="utf-8") as handle:
        body = handle.read()
    if body.startswith("---\n"):
        die("body starts with YAML frontmatter; strip it before uploading to Buttondown")
    if editor_mode:
        marker = f"<!-- buttondown-editor-mode: {editor_mode} -->"
        if not body.startswith("<!-- buttondown-editor-mode:"):
            body = marker + body
    return body


def parse_metadata(raw):
    if not raw:
        return None
    try:
        value = json.loads(raw)
    except json.JSONDecodeError as exc:
        die(f"--metadata-json must be valid JSON: {exc}")
    if not isinstance(value, dict):
        die("--metadata-json must decode to an object")
    return value


def add_common_email_fields(parser, require_subject=False):
    parser.add_argument("--subject", required=require_subject)
    parser.add_argument("--body-file")
    parser.add_argument("--editor-mode", choices=["plaintext", "fancy"])
    parser.add_argument("--slug")
    parser.add_argument("--description")
    parser.add_argument("--canonical-url")
    parser.add_argument("--image")
    parser.add_argument("--archival-mode", choices=[
        "disabled",
        "enabled",
        "enabled_for_paid_subscribers",
        "enabled_for_subscribers",
    ])
    parser.add_argument("--commenting-mode", choices=[
        "disabled",
        "enabled",
        "enabled_for_paid_subscribers",
    ])
    parser.add_argument("--metadata-json")
    parser.add_argument("--secondary-id", type=int)
    parser.add_argument("--featured", action="store_true")
    parser.add_argument("--related-email-id", action="append", default=[])
    parser.add_argument("--dry-run", action="store_true")


def build_email_payload(args, create=False):
    payload = {}
    if create:
        payload["status"] = "draft"
    for attr, key in [
        ("subject", "subject"),
        ("slug", "slug"),
        ("description", "description"),
        ("canonical_url", "canonical_url"),
        ("image", "image"),
        ("archival_mode", "archival_mode"),
        ("commenting_mode", "commenting_mode"),
        ("secondary_id", "secondary_id"),
    ]:
        value = getattr(args, attr, None)
        if value is not None:
            payload[key] = value
    if getattr(args, "body_file", None):
        payload["body"] = read_body(args.body_file, args.editor_mode)
    metadata = parse_metadata(getattr(args, "metadata_json", None))
    if metadata is not None:
        payload["metadata"] = metadata
    if getattr(args, "featured", False):
        payload["featured"] = True
    if getattr(args, "related_email_id", None):
        payload["related_email_ids"] = args.related_email_id
    if create and not payload.get("subject"):
        die("--subject is required")
    if create and "body" not in payload:
        die("--body-file is required")
    return payload


def request(method, path, payload=None, query=None, context=None, dry_run=False):
    [REDACTED_SECRET] dry_run)
    base = os.environ.get("BUTTONDOWN_API_BASE", DEFAULT_API_BASE).rstrip("/")
    url = f"{base}{path}"
    if query:
        url += "?" + urllib.parse.urlencode(query)
    headers = {
        "Accept": "application/json",
        "Content-Type": "application/json",
    }
    if [REDACTED_SECRET]"Authorization"] = f"Token {api_key}"
    context = context or os.environ.get("BUTTONDOWN_CONTEXT")
    if context:
        headers["Buttondown-Context"] = context

    body = None if payload is None else json.dumps(payload).encode()
    if dry_run:
        preview = {
            "method": method,
            "url": url,
            "headers": {**headers, "Authorization": "[REDACTED]" if api_key else "[unset]"},
            "json": payload,
        }
        print(json.dumps(preview, indent=2, ensure_ascii=False))
        return None

    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            text = response.read().decode()
            if not text:
                return {}
            return json.loads(text)
    except urllib.error.HTTPError as exc:
        text = exc.read().decode(errors="replace")
        safe = redact(text, api_key)
        try:
            data = json.loads(safe)
            detail = data.get("detail") or data
        except Exception:
            detail = safe
        die(f"Buttondown HTTP {exc.code}: {detail}", code=1)
    except urllib.error.URLError as exc:
        die(f"Buttondown request failed: {exc}", code=1)


def print_json(data):
    if data is not None:
        print(json.dumps(data, indent=2, ensure_ascii=False))


def command_create_draft(args):
    payload = build_email_payload(args, create=True)
    print_json(request("POST", "/emails", payload, context=args.context, dry_run=args.dry_run))


def command_update_draft(args):
    payload = build_email_payload(args, create=False)
    if not payload:
        die("nothing to update")
    print_json(request("PATCH", f"/emails/{args.email_id}", payload, context=args.context, dry_run=args.dry_run))


def command_get(args):
    print_json(request("GET", f"/emails/{args.email_id}", context=args.context))


def command_render(args):
    print_json(request("GET", f"/emails/{args.email_id}/renders", context=args.context))


def command_list(args):
    query = {}
    if args.status:
        query["status"] = args.status
    print_json(request("GET", "/emails", query=query, context=args.context))


def command_send_draft(args):
    payload = {}
    if args.recipient:
        payload["recipients"] = args.recipient
    if args.subscriber:
        payload["subscribers"] = args.subscriber
    if not payload:
        die("provide --recipient or --subscriber")
    print_json(request("POST", f"/emails/{args.email_id}/send-draft", payload, context=args.context, dry_run=args.dry_run))


def main():
    parser = argparse.ArgumentParser(description="Buttondown API helper for draft-first email workflows.")
    parser.add_argument("--context", help="Buttondown newsletter username for Buttondown-Context header")
    sub = parser.add_subparsers(dest="command", required=True)

    create = sub.add_parser("create-draft", help="Create a Buttondown draft email")
    add_common_email_fields(create, require_subject=True)
    create.set_defaults(func=command_create_draft)

    update = sub.add_parser("update-draft", help="Update an existing Buttondown email")
    update.add_argument("email_id")
    add_common_email_fields(update, require_subject=False)
    update.set_defaults(func=command_update_draft)

    get = sub.add_parser("get", help="Retrieve an email")
    get.add_argument("email_id")
    get.set_defaults(func=command_get)

    render = sub.add_parser("render", help="Retrieve rendered HTML for an email")
    render.add_argument("email_id")
    render.set_defaults(func=command_render)

    list_cmd = sub.add_parser("list", help="List emails")
    list_cmd.add_argument("--status")
    list_cmd.set_defaults(func=command_list)

    send = sub.add_parser("send-draft", help="Send a draft preview to recipients or subscribers")
    send.add_argument("email_id")
    send.add_argument("--recipient", action="append", default=[])
    send.add_argument("--subscriber", action="append", default=[])
    send.add_argument("--dry-run", action="store_true")
    send.set_defaults(func=command_send_draft)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


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