For Agents & Automation

HTML Hoster Agent Upload API

Host generated web pages, images, PDFs and Markdown from any agent — WorkBuddy, Claude Code, Codex, or your own script — and get back a public URL. No browser, no CAPTCHA, no API key required.

For agents: don't parse this HTML page — fetch the raw files directly: /agent/SKILL.md and /agent/upload.py.

Open source: this skill is also published on GitHub — star it, report issues or send a pull request.

Endpoints

Base URL: https://api.htmlhoster.com.

  • POST /agent/upload — HTML / images / PDF / ZIP (unzip hosting)
  • POST /agent/markdown — Markdown rendering & hosting
  • DELETE /api/uploads/{uploadId} — remove a page you uploaded

Both upload endpoints return JSON containing entryUrl (the public page), files (per-file URLs) and deleteToken.

Supported formats

ModeEndpointNotes
HTML/agent/uploadHTML gets automatic nofollow + floating report widget injected.
Images (png/jpg/gif/webp/svg/…)/agent/uploadServed as-is.
PDF/agent/uploadServed as-is.
ZIP (unzip)/agent/upload + unzipEach entry hosted; inner index.html becomes the entry URL.
Markdown/agent/markdownRendered to HTML (GFM + task lists + KaTeX/Mermaid/CDN), then hosted.

Rate limit

Single client IP: 50 uploads per day (both endpoints share the quota). Exceeding returns HTTP 429. On 429, stop and surface the limit to the user — do not retry in a loop, and do not attempt to work around the quota. Suggest uploading manually in the browser at https://htmlhoster.com (the web tools have no daily quota), or wait for the quota to reset. A login / API-key system is planned to lift the quota for identified users.

Deleting an upload

Every successful upload returns a deleteToken. It is scoped to that single uploadId and is the only way for you to remove the page — the token is a random value stored server-side (under a non-public prefix, not derivable from the id), so keep it safe; it cannot be looked up or re-issued later. Send DELETE /api/uploads/{uploadId} with header X-Delete-Token: <token>, or use the bundled script:

python scripts/upload.py --mode delete --id A1B2C3D4 --token <deleteToken>

Deleting can take a while (object listing + CDN purge) — if the request times out, the deletion has usually still succeeded: verify by fetching entryUrl (a deleted page returns 404). Once deleted, the token is destroyed, so re-running delete on the same id returns 401 — treat that as "already gone". Agents should keep the token in working notes for the session so a mistaken upload can be undone, but must never publish it next to the public link.

Acceptable use

Upload only content you created or are authorised to publish. Phishing and brand-impersonation pages, credential-harvesting forms, malware, crypto-mining or other hidden scripts, spam / SEO link farms, and anything illegal are prohibited. Pages are served from a shared public domain and may be removed without notice; abusive clients may be blocked. Never upload secrets, credentials, or personal data — every uploaded file is reachable by anyone holding the URL.

Install & use the skill

Copy the message below and send it to your agent (Claude Code, Codex, WorkBuddy, or any other). It tells the agent to read this page and install the skill for its own platform — no manual setup needed.

Install the "htmlhoster-upload" skill by reading the documentation at
https://htmlhoster.com/agent/ and following your platform's conventions to
set it up. Use it whenever I need to host HTML, images, PDFs, ZIP archives,
or Markdown and get back a public URL.
# HTML / images / PDF, optionally unzip a zip
python scripts/upload.py --mode html --file index.html --file logo.png --unzip

# Markdown via zip package (contains .md + assets)
python scripts/upload.py --mode markdown --package doc.zip

# Markdown via single .md + assets
python scripts/upload.py --mode markdown --file doc.md --asset img1.png --asset img2.png

# Delete a page you uploaded (token comes from the upload response)
python scripts/upload.py --mode delete --id A1B2C3D4 --token <deleteToken>

Env: HTMLHOSTER_API_BASE (optional, default https://api.htmlhoster.com).

SKILL.md

Copy this into your agent's skill directory as SKILL.md.

---
name: htmlhoster-upload
description: >-
  Publish HTML pages, images, PDFs, ZIP (unzip hosting) and Markdown documents to
  htmlhoster.com and get back a public URL. Use this whenever an agent needs to
  host generated web pages, static assets, or rendered markdown and return a
  shareable link. Works for WorkBuddy, Claude Code, Codex, and any CLI agent.
---

# htmlhoster-upload

Host generated content on **htmlhoster.com** through its programmatic Agent upload
API and receive a public URL (`entryUrl`). No browser, no CAPTCHA, no API key
required (rate-limited per IP instead).

## When to use
- You produced an `.html` page (or a set of HTML + assets) and need a public link.
- You have images / PDFs to host.
- You have a `.zip` whose contents should be hosted (e.g. a built site).
- You have Markdown (`.md` + assets, or a zip package) to render into HTML and host.

## Before you upload: scan for sensitive info
htmlhoster is a **public** site — every uploaded file is reachable by anyone who
has the URL, with no authentication. Treat every upload as publishing to the
world.

**Step 1 — the agent reviews the content itself (mandatory, do not skip).**
Before you call the upload, you (the agent) must actually *read* the file(s) you
are about to publish and eyeball them for sensitive material. Do **not** rely on
the script's scan alone — the scan is mechanical and will miss things like:
- credentials in unusual or obfuscated forms (split strings, base64, env lookups)
- personal data that isn't a literal "key" pattern (emails, phone numbers,
  addresses, internal hostnames, real names of third parties)
- anything you generated that you would not paste into a public chat
If you spot something sensitive, stop and ask the user before publishing.

**Step 2 — the script's automatic scan (defence in depth).**
The bundled `scripts/upload.py` also runs a secret scan **before every upload**
and aborts (non-zero exit) if it detects a likely secret. If it aborts, do **not**
blindly re-run with `--no-check` to push the content through — review the flagged
file with the user first. Only pass `--no-check` when the user has explicitly
confirmed the content is safe to publish. Even with `--no-check`, never upload
real credentials or personal data: the final responsibility is yours, not the
script's.

## Endpoint
- Base: `https://api.htmlhoster.com` (override with env `HTMLHOSTER_API_BASE`)
- `POST /agent/upload`    — HTML / images / PDF / ZIP
- `POST /agent/markdown`  — Markdown rendering & hosting

- `DELETE /api/uploads/{uploadId}` — remove a page you uploaded (see *Deleting*)

Both upload endpoints return JSON containing `entryUrl` (the public page), `files`
(per-file URLs) and `deleteToken` (see *Deleting* below).

## Supported formats
| Mode | Endpoint | Notes |
|------|----------|-------|
| HTML | `/agent/upload` | HTML gets automatic `nofollow` + floating report widget injected. |
| Images (png/jpg/gif/webp/svg/...) | `/agent/upload` | Served as-is. |
| PDF | `/agent/upload` | Served as-is. |
| ZIP (unzip) | `/agent/upload` with `--unzip` | Each entry hosted; inner `index.html` becomes the entry URL. |
| Markdown | `/agent/markdown` | Rendered to HTML (GFM + task lists + KaTeX/Mermaid/CDN), then hosted. |

## Rate limit
Single client IP: **50 uploads per day** (both endpoints share the quota). Exceeding
returns HTTP `429`.

On `429`: stop and tell the user the daily quota is exhausted. Do **not** retry in a
loop, and do **not** attempt to work around the limit. Suggest the user upload
manually in the browser at https://htmlhoster.com — the web tools have no daily
quota — or wait for the quota to reset. A login / API-key system is planned to
lift the quota for identified users.

## Acceptable use
Upload only content you created or are authorised to publish. Prohibited: phishing and
brand-impersonation pages, credential-harvesting forms, malware, crypto-mining or other
hidden scripts, spam / SEO link farms, and anything illegal. Pages are served from a
shared public domain and may be removed without notice; abusive clients may be blocked.
Never upload secrets, credentials, or personal data — every uploaded file is reachable
by anyone holding the URL. If you are unsure whether content is allowed, ask the user
before uploading.

## How to upload (recommended: the bundled script)
This skill ships `scripts/upload.py` — **pure standard library, zero dependencies**.

```bash
# HTML / images / PDF, optionally unzip a zip
python scripts/upload.py --mode html --file index.html --file logo.png --unzip

# Markdown via zip package (contains .md + assets)
python scripts/upload.py --mode markdown --package doc.zip

# Markdown via single .md + assets
python scripts/upload.py --mode markdown --file doc.md --asset img1.png --asset img2.png
```

Environment:
- `HTMLHOSTER_API_BASE` (optional): API base, default `https://api.htmlhoster.com`.
- `HTMLHOSTER_UPLOAD_EXTRA_HEADERS` (optional): extra request headers, `k1:v1;k2:v2`.

On success it prints the JSON (grab `entryUrl`). Exit code `0` on success, non-zero on
failure (the error body is printed to stderr).

## Deleting an upload
Every successful upload returns a `deleteToken`. It is scoped to that one `uploadId`
and is the **only** way for you to remove the page. The token is a random value stored
server-side on htmlhoster (under a non-public prefix, not derivable from the id) — so
keep it safe. You cannot re-derive or look it up later.

```bash
python scripts/upload.py --mode delete --id A1B2C3D4 --token <deleteToken>
```

Or directly: `DELETE /api/uploads/{uploadId}` with header `X-Delete-Token: <token>`.

Guidance for agents:
- Report `entryUrl` to the user, and **keep `deleteToken` in your working notes** for
  the rest of the session so a mistaken upload can be undone.
- If the user asks to remove something you uploaded earlier in the session, use the
  token you saved. If you no longer have it, say so plainly — you cannot recover it,
  but the site owner can still take the page down if necessary.
- Deletion can take a while (object listing + CDN purge); the script waits up to 180s.
  If it times out, the deletion has usually still succeeded server-side — verify by
  fetching `entryUrl` (a deleted page returns `404`) instead of retrying blindly.
- Once a page is deleted its token is destroyed, so re-running delete on the same id
  returns `401`, not `200`. Treat `401` after a confirmed delete as "already gone".
- Never publish the `deleteToken` alongside the public link (e.g. in a shared doc or
  chat message) — anyone holding it can delete the page.

## Porting to other agents (Claude Code / Codex)
The script is self-contained. To use it elsewhere:
1. Copy `scripts/upload.py` to the agent's working directory (or anywhere on PATH).
2. Run it with the same CLI shown above.
3. No API key needed; respect the 50/day per-IP limit.

A full human/agent-readable doc page lives at `https://htmlhoster.com/agent/`, and the
raw script is downloadable at `https://htmlhoster.com/agent/upload.py`.

upload.py

Copy this into your agent's skill directory as scripts/upload.py.

#!/usr/bin/env python3
"""htmlhoster-upload - publish local files to htmlhoster.com via its Agent upload API.

Pure standard-library implementation (no third-party deps), so any agent
(WorkBuddy / Claude Code / Codex / ...) can run it directly.

htmlhoster is a PUBLIC site: every uploaded file is reachable by anyone who has
the URL, with no authentication. To reduce accidental leaks, the script scans
the content for likely secrets / credentials BEFORE every upload and aborts if
it finds any. Pass --no-check to skip the scan, but only with content you are
certain is safe to publish.

Usage:
  # HTML / images / PDF / ZIP(unzip) hosting
  python upload.py --mode html --file index.html --file logo.png --unzip

  # Markdown - zip package mode (contains .md + assets)
  python upload.py --mode markdown --package doc.zip

  # Markdown - single file + assets mode
  python upload.py --mode markdown --file doc.md --asset img1.png --asset img2.png

  # Delete a page you uploaded (needs the deleteToken from the upload response)
  python upload.py --mode delete --id A1B2C3D4 --token <deleteToken>

Environment:
  HTMLHOSTER_API_BASE            API base URL, default https://api.htmlhoster.com
  HTMLHOSTER_UPLOAD_EXTRA_HEADERS  optional extra headers, "k1:v1;k2:v2"

Output: the API JSON. A successful upload includes both `entryUrl` (the public
link) and `deleteToken` — keep the token if the page may need removing later,
it is the only way to delete that page and it cannot be recovered.
Exit 0 on success, non-zero on failure.
"""
import argparse
import datetime
import json
import os
import re
import sys
import urllib.error
import urllib.request
import zipfile

API_BASE_DEFAULT = "https://api.htmlhoster.com"
BOUNDARY = "----htmlhosterUploadBoundary%s" % datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")


# ---------------------------------------------------------------------------
# Sensitive-information scan
#
# htmlhoster is a public site; anyone with the URL can read uploaded files.
# Before uploading we scan text-like files (and the entries of a markdown zip
# package) for likely secrets / credentials. Matched values are never printed —
# only the file, the kind of match, and a line number — so the scan itself does
# not re-leak what it found.
# ---------------------------------------------------------------------------
# Only scan these text-like extensions; binaries / images / pdf are skipped.
_SCAN_TEXT_EXTS = {
    ".html", ".htm", ".md", ".markdown", ".txt", ".text",
    ".json", ".js", ".mjs", ".ts", ".jsx", ".tsx", ".css", ".scss", ".less",
    ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
    ".csv", ".log", ".sh", ".bash", ".zsh", ".py", ".rb", ".go", ".java",
    ".c", ".cpp", ".h", ".hpp", ".php", ".sql", ".rst", ".tex", ".gradle",
}
# Filenames that are sensitive regardless of content.
_SENSITIVE_NAME_TOKENS = (
    "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
    ".env", "credentials", "secrets", ".npmrc", ".netrc",
    "authorized_keys", "known_hosts",
)
_SENSITIVE_NAME_EXTS = (".pem", ".key", ".p12", ".pfx", ".keystore", ".jks", ".kdbx")
# (compiled regex, human label, is_assignment).
# For assignment patterns the captured value is checked against placeholders;
# for the rest the whole match is structurally specific enough to trust.
_SECRET_PATTERNS = [
    (re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP |ENCRYPTED )?PRIVATE KEY-----"),
     "private key block", False),
    (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key id", False),
    (re.compile(r"(?:AKID|LTAI)[0-9A-Za-z]{8,}"), "Alibaba cloud access key", False),
    (re.compile(r"AIza[0-9A-Za-z_\-]{35}"), "Google API key", False),
    (re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[0-9A-Za-z]{36}"), "GitHub token", False),
    (re.compile(r"github_pat_[0-9A-Za-z_]{22,}"), "GitHub fine-grained PAT", False),
    (re.compile(r"glpat-[0-9A-Za-z_\-]{20,}"), "GitLab PAT", False),
    (re.compile(r"xox[baprs]-[0-9A-Za-z\-]{10,}"), "Slack token", False),
    (re.compile(r"sk-[A-Za-z0-9]{20,}"), "LLM API key (sk-)", False),
    (re.compile(r"cfat_[0-9A-Za-z]{16,}"), "Cloudflare API token", False),
    (re.compile(r"ya29\.[0-9A-Za-z_\-]+"), "Google OAuth token", False),
    (re.compile(r"strip[e]?_[a-z]{2,}_[0-9A-Za-z]{10,}"), "Stripe key", False),
    (re.compile(
        r"(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|"
        r"auth[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*['\"]?([^\s'\"]{12,})"),
     "credential assignment", True),
    (re.compile(r"(?:mongodb|postgres|postgresql|mysql|redis|amqp)://[^\s:/]+:[^\s@]{4,}@"),
     "DB / connection string with credentials", False),
]
# If a credential assignment's value (lowercased) contains any of these, treat
# it as an obvious placeholder and skip — e.g. `password = "your_password"`.
_PLACEHOLDER_VALUES = {
    "your", "example", "exampled", "changeme", "changeit", "test", "demo",
    "sample", "placeholder", "todo", "fixme", "none", "null", "undefined",
    "redacted", "dummy", "fake", "mock", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx",
    "secret", "password",
}
_SCAN_SIZE_CAP = 5 * 1024 * 1024  # skip files larger than 5 MB


def _is_sensitive_filename(path):
    base = os.path.basename(path).lower()
    if base in _SENSITIVE_NAME_TOKENS:
        return True
    for tok in _SENSITIVE_NAME_TOKENS:
        if tok in base:  # e.g. prod.env, my-credentials.json
            return True
    for ext in _SENSITIVE_NAME_EXTS:
        if base.endswith(ext):
            return True
    return False


def _scan_text(name, text):
    """Return [(label, lineno), ...] for the given text content."""
    findings = []
    for lineno, line in enumerate(text.splitlines(), 1):
        for rx, label, is_assign in _SECRET_PATTERNS:
            m = rx.search(line)
            if not m:
                continue
            if is_assign:
                val = m.group(1).lower()
                if any(p in val for p in _PLACEHOLDER_VALUES):
                    break  # placeholder, not a real secret
            findings.append((label, lineno))
            break
    return findings


def _scan_bytes(name, data):
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError:
        try:
            text = data.decode("latin-1")
        except Exception:
            return []
    return _scan_text(name, text)


def _scan_files(specs):
    """specs: list of (path, data_bytes). Returns [(path, label, lineno), ...]."""
    findings = []
    for path, data in specs:
        if _is_sensitive_filename(path):
            findings.append((path, "sensitive filename (key / credential file)", 0))
            continue
        ext = os.path.splitext(path)[1].lower()
        if ext not in _SCAN_TEXT_EXTS or len(data) > _SCAN_SIZE_CAP:
            continue
        for label, lineno in _scan_bytes(path, data):
            findings.append((path, label, lineno))
    return findings


def _scan_zip(path):
    """Scan the text-like entries of a markdown zip package."""
    findings = []
    try:
        with zipfile.ZipFile(path) as zf:
            for info in zf.infolist():
                if info.is_dir() or info.file_size > _SCAN_SIZE_CAP:
                    continue
                if _is_sensitive_filename(info.filename):
                    findings.append((info.filename, "sensitive filename (key / credential file)", 0))
                    continue
                ext = os.path.splitext(info.filename)[1].lower()
                if ext not in _SCAN_TEXT_EXTS:
                    continue
                try:
                    data = zf.read(info)
                except Exception:
                    continue
                for label, lineno in _scan_bytes(info.filename, data):
                    findings.append((info.filename, label, lineno))
    except zipfile.BadZipFile:
        pass
    return findings


def _maybe_abort_on_findings(findings):
    if not findings:
        return
    sys.stderr.write("\n[!] SENSITIVE-INFORMATION SCAN\n")
    sys.stderr.write("    htmlhoster is a PUBLIC site - anyone with the URL can read\n")
    sys.stderr.write("    uploaded files. The following potential secrets / credentials\n")
    sys.stderr.write("    were found. Review them before publishing:\n\n")
    for path, label, lineno in findings:
        loc = " (line %d)" % lineno if lineno else ""
        sys.stderr.write("    - %s  [%s]%s\n" % (path, label, loc))
    sys.stderr.write("\n    If you are CERTAIN the content is safe to publish, re-run with\n")
    sys.stderr.write("    --no-check. Otherwise remove the sensitive data first.\n\n")
    sys.exit(3)


def _encode_multipart(fields):
    """fields: list of (name, value, filename_or_None, content_type_or_None)."""
    parts = []
    for name, value, filename, ctype in fields:
        disp = 'form-data; name="%s"' % name
        if filename:
            disp += '; filename="%s"' % filename
        header = "Content-Disposition: %s\r\n" % disp
        if ctype:
            header += "Content-Type: %s\r\n" % ctype
        body = value if isinstance(value, bytes) else value.encode("utf-8")
        parts.append(("--%s\r\n%s\r\n" % (BOUNDARY, header)).encode("utf-8") + body + b"\r\n")
    body = b"".join(parts) + ("--%s--\r\n" % BOUNDARY).encode("utf-8")
    return body


def _guess_content_type(path):
    ext = os.path.splitext(path)[1].lower()
    table = {
        ".html": "text/html", ".htm": "text/html",
        ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
        ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
        ".pdf": "application/pdf",
        ".md": "text/markdown", ".markdown": "text/markdown",
        ".zip": "application/zip",
        ".css": "text/css", ".js": "application/javascript",
        ".json": "application/json", ".txt": "text/plain",
    }
    return table.get(ext, "application/octet-stream")


USER_AGENT = "htmlhoster-upload-skill/1.0 (+https://htmlhoster.com/agent/)"


def _send(req, extra_headers):
    req.add_header("User-Agent", USER_AGENT)
    for k, v in (extra_headers or {}).items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            return resp.status, resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")
    except Exception as e:  # noqa: BLE001 - surface any network error to caller
        return 0, str(e)


def _post(api_base, endpoint, fields, extra_headers):
    url = api_base.rstrip("/") + endpoint
    data = _encode_multipart(fields)
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "multipart/form-data; boundary=%s" % BOUNDARY)
    return _send(req, extra_headers)


def _delete(api_base, upload_id, token, extra_headers):
    url = "%s/api/uploads/%s" % (api_base.rstrip("/"), upload_id)
    req = urllib.request.Request(url, method="DELETE")
    req.add_header("X-Delete-Token", token)
    return _send(req, extra_headers)


def main():
    p = argparse.ArgumentParser(description="Upload files to htmlhoster via Agent endpoint")
    p.add_argument("--mode", choices=["html", "markdown", "delete"], default="html")
    p.add_argument("--id", help="(delete) uploadId of the page to remove")
    p.add_argument("--token", help="(delete) deleteToken returned by the upload response")
    p.add_argument("--file", action="append", default=[], help="file path (html: multiple; markdown: first is .md)")
    p.add_argument("--path", action="append", default=[], help="(html) relative path per --file, optional")
    p.add_argument("--unzip", action="store_true", help="(html) unzip zip and host contents")
    p.add_argument("--package", help="(markdown) zip package path (contains .md + assets)")
    p.add_argument("--asset", action="append", default=[], help="(markdown) asset file path")
    p.add_argument("--asset-path", action="append", default=[], help="(markdown) asset relative path, optional")
    p.add_argument("--api-base", default=os.environ.get("HTMLHOSTER_API_BASE", API_BASE_DEFAULT))
    p.add_argument("--no-check", action="store_true",
                   help="skip the pre-upload sensitive-information scan (use only with trusted content)")
    args = p.parse_args()

    extra = {}
    eh = os.environ.get("HTMLHOSTER_UPLOAD_EXTRA_HEADERS")
    if eh:
        for item in eh.split(";"):
            if ":" in item:
                k, v = item.split(":", 1)
                extra[k.strip()] = v.strip()

    if args.mode == "delete":
        if not args.id or not args.token:
            sys.stderr.write("error: --id and --token are required for delete mode\n")
            sys.exit(2)
        status, raw = _delete(args.api_base, args.id, args.token, extra)
    elif args.mode == "html":
        if not args.file:
            sys.stderr.write("error: --file required for html mode\n")
            sys.exit(2)
        specs = []
        fields = []
        for i, f in enumerate(args.file):
            with open(f, "rb") as fh:
                data = fh.read()
            specs.append((f, data))
            fields.append(("files", data, os.path.basename(f), _guess_content_type(f)))
            if i < len(args.path) and args.path[i]:
                fields.append(("paths", args.path[i], None, None))
        if args.unzip:
            fields.append(("unzip", "true", None, None))
        _maybe_abort_on_findings(_scan_files(specs) if not args.no_check else [])
        status, raw = _post(args.api_base, "/agent/upload", fields, extra)
    else:  # markdown
        fields = []
        if args.package:
            findings = _scan_zip(args.package) if not args.no_check else []
            with open(args.package, "rb") as fh:
                data = fh.read()
            fields.append(("package", data, os.path.basename(args.package), "application/zip"))
        else:
            if not args.file:
                sys.stderr.write("error: --file (doc.md) or --package required for markdown mode\n")
                sys.exit(2)
            specs = []
            with open(args.file[0], "rb") as fh:
                data = fh.read()
            specs.append((args.file[0], data))
            fields.append(("markdown", data, os.path.basename(args.file[0]), "text/markdown"))
            for i, a in enumerate(args.asset):
                with open(a, "rb") as fh:
                    adata = fh.read()
                specs.append((a, adata))
                fields.append(("assets", adata, os.path.basename(a), _guess_content_type(a)))
                if i < len(args.asset_path) and args.asset_path[i]:
                    fields.append(("asset_paths", args.asset_path[i], None, None))
            findings = _scan_files(specs) if not args.no_check else []
        _maybe_abort_on_findings(findings)
        status, raw = _post(args.api_base, "/agent/markdown", fields, extra)

    try:
        payload = json.loads(raw) if raw else {}
    except json.JSONDecodeError:
        payload = {"raw": raw}
    action = "delete" if args.mode == "delete" else "upload"
    if status and 200 <= status < 300:
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        sys.exit(0)
    sys.stderr.write("%s failed (HTTP %s):\n%s\n" % (action, status, raw))
    sys.exit(1)


if __name__ == "__main__":
    main()