# Create Artifact

> Upload an HTML report to Folslate and return a public fol.ink URL. Prefer HTML over Markdown. Use to share plans, reports, audits, findings, or other user-facing material as a link, when output is too long to paste inline, or when reading a fol.ink link.

- Skill: `bhagyamudgal/create-artifact` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bhagyamudgal/create-artifact`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bhagyamudgal/create-artifact/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: bhagyamudgal (https://skillmd.com/u/bhagyamudgal)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bhagyamudgal/create-artifact

---


# Create artifact

Folslate hosts one HTML or Markdown file at a public URL. There is no account and no token. I POST the bytes to `api.folslate.com` and get back a `fol.ink` link. I default to HTML for every report.

## Prefer HTML

I build an HTML report unless the document is plain text with no tables, charts, or custom styling. HTML keeps inline `<style>`, images load as `data:` URIs, and I control the layout. Markdown is a fallback for simple text-only documents, not the default.

## Check these before uploading

None of the three can be undone after the upload, so I read them as a checklist, not a suggestion.

**The link is public.** Anyone holding the URL reads the document, with no authentication. Folslate has no delete, list, or edit endpoint, so I cannot revoke a link once I give it out. I keep credentials, keys, customer records, and anything the user has not agreed to publish out of the upload.

**It expires in one day.** The `201` carries the exact `expires` timestamp. I hand it to the user beside the link. After it, reads answer `404`.

**The page is inert.** A hosted document cannot run JavaScript, load an external stylesheet, font, or image, submit a form, or be framed. An HTML report that pulls a chart library from a CDN renders as a blank page. Folslate also strips every `<meta http-equiv>` and every `<noscript>` at upload.

What I can do instead depends on the upload type. A `text/html` upload keeps inline `<style>`, and images load as `data:` URIs, so I inline the SVG the CDN would have drawn. A `text/markdown` upload escapes raw HTML rather than passing it through, so an `<svg>` or `<style>` block written into Markdown arrives as visible text. Folslate styles Markdown itself, and a chart has to become a `data:` image. This is why HTML is the default.

## Upload

Immediately before any POST below, I invoke `preflight-mutations` for the exact artifact and Folslate target. Its mutation card must cover the document bytes, any sensitive material they contain, the public one-day retention, and the absence of revocation or deletion. Because this is an irreversible off-box publication, I let `preflight-mutations` decide whether the current authorization is fresh and exact enough. I continue only on `ready`. On `confirmation-required` I wait for the named confirmation. I re-check the card invalidators before sending.

I run the upload preflight above for this exact HTML artifact before this POST.

```bash
curl -sS -X POST https://api.folslate.com/v1/upload \
  -H 'content-type: text/html' \
  -H 'x-folslate-title: Release notes' \
  --data-binary @page.html
```

Markdown is the fallback for simple text-only documents:

```bash
curl -sS -X POST https://api.folslate.com/v1/upload \
  -H 'content-type: text/markdown' \
  --data-binary @report.md
```

I use `--data-binary`, never `-d`. `-d` strips newlines and collapses the whole file into one paragraph, which destroys Markdown.

The `content-type` is `text/html` by default, `text/markdown` for the text-only fallback. Folslate rejects every other value with `415`, and it decides the pipeline. A `text/markdown` body is converted to HTML and wrapped in a page shell, while a `text/html` body is sanitized and kept.

A `201` looks like this.

```json
{
    "success": true,
    "message": "Document stored",
    "data": {
        "url": "https://fol.ink/doc_01k3n8w5q2r7v0xyz4a6bcdefg",
        "expires": "2026-08-26T09:12:44.000Z",
        "title": "Quarterly report"
    },
    "error": null
}
```

I report `data.url` and `data.expires` together. A link without its expiry reads as permanent, and it is not.

## Read a document back

```bash
curl -sS -i https://fol.ink/doc_01k3n8w5q2r7v0xyz4a6bcdefg
```

A `200` with `Content-Type: text/html` returns the stored document. Failures use a non-`200` status and `Content-Type: application/json`, so I parse the JSON envelope only for that media type. When the status and media type disagree, I treat the response as unexpected instead of guessing from its first byte.

`curl -I` on the same URL tells me a link is alive without recording a view.

Ids are `doc_` followed by 26 lowercase Crockford Base32 characters, which exclude `i`, `l`, `o`, and `u`. I use the `url` the upload returned rather than building one.

## Titles

Every stored document carries a `<title>`. Folslate takes it from `X-Folslate-Title`, else the document own `<title>` or first heading, else the document id. The HTML upload above already sends `x-folslate-title`.

The `201` echoes the title the document actually got, so I read `data.title` rather than fetching the document back to check. Folslate collapses whitespace and cuts the title at 200 characters, so a long one comes back changed.

A header value carries UTF-8 correctly from a CLI or an agent. Browser `fetch()` cannot reliably send a non-ASCII header, so in browser code I put the title in the document instead, where a Markdown `#` heading is read from the body as UTF-8.

## When it fails

Every response from `api.folslate.com`, and every failure on either host, has the same four fields, `success`, `message`, `data`, and `error`. `data` is `null` whenever `success` is `false`. I branch on `error.code`. The `message` is prose and gets reworded.

| `error.code`             | Status | What to do                                                                                                       |
| ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------- |
| `unsupported_media_type` | 415    | Send `text/html`, or `text/markdown` for the text-only fallback. `error.accepts` lists them.                     |
| `payload_too_large`      | 413    | The body is over `error.maxBytes`. Split the document or trim it.                                                |
| `unprocessable_document` | 422    | Rendering failed. The same bytes fail the same way, so change them before retrying.                              |
| `rate_limited`           | 429    | Wait the `Retry-After` seconds, then retry.                                                                      |
| `storage_unavailable`    | 503    | Transient. Retry once.                                                                                           |
| `not_found`              | 404    | On a read: expired, never existed, or a malformed id. These are deliberately indistinguishable, so do not retry. |

## Limits

Max body is 1 MB. Uploads are rate limited per IP at 20 a minute, counted per Cloudflare location rather than globally, so I treat it as a brake on bulk traffic rather than an exact quota. Retention is one day from upload.

