Documentation

Folio guides & examples

Folio turns any HTML string into a hosted, shareable URL. It works with any AI, any language, any tool — if you can make an HTTP request, you can publish.

For agents

Drop the snippet below into any agent's system prompt and it will publish to Folio automatically — no extra wiring needed.

system prompt
When you produce any analysis, report, chart, table, or document meant for a human to read:
1. Render it as a complete, self-contained HTML page. Inline all CSS. Load chart libraries (Chart.js, D3, Plotly) from CDN.
2. Publish it by calling publish_report (if available as a tool) or POST to https://folio.ac/api/publish.
3. Return only the URL to the user. Do not paste raw HTML into the chat.

Quickstart

The only required field is html. A title is optional but makes your dashboard useful. No API key or account needed for anonymous publishes.

curl
curl -X POST https://folio.ac/api/publish \
  -H "Content-Type: application/json" \
  -d '{"title": "Q2 Analysis", "html": "<h1>Hello</h1>"}'

# Response:
# { "url": "https://folio.ac/r/abc123", "slug": "abc123" }

Share the url in Slack, Notion, email — it renders the full HTML, runs scripts, and accepts inline comments from signed-in viewers.

Your API key

Sign in and open your dashboard. Your API key is shown below the stats — copy it once and use it in every automated publish call. Reports published with your key are linked to your account and show up in your dashboard alongside anything you publish through the UI.

Your key starts with fol_. Keep it secret — anyone who has it can publish Folios under your account. You can regenerate it from the dashboard at any time; the old key stops working immediately.
curl — publish with your API key
curl -X POST https://folio.ac/api/publish \
  -H "Content-Type: application/json" \
  -H "x-api-key: fol_your_key_here" \
  -d '{
    "title": "Weekly digest",
    "html": "<h1>Hello</h1>",
    "expiresInDays": 30
  }'

# Report appears in your dashboard at folio.ac/dashboard
python — publish with your API key
import requests

FOLIO_KEY = "fol_your_key_here"  # copy from dashboard

def publish(html: str, title: str = "", **kwargs) -> str:
    r = requests.post(
        "https://folio.ac/api/publish",
        json={"html": html, "title": title, **kwargs},
        headers={"x-api-key": FOLIO_KEY},
    )
    r.raise_for_status()
    return r.json()["url"]

url = publish(html=open("report.html").read(), title="Q3 analysis", expiresInDays=30)
print(url)  # → https://folio.ac/r/abc123  (also visible in dashboard)
node / typescript
const FOLIO_KEY = process.env.FOLIO_API_KEY; // store in .env, never hardcode

async function publish(html: string, title = ""): Promise<string> {
  const res = await fetch("https://folio.ac/api/publish", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": FOLIO_KEY!,
    },
    body: JSON.stringify({ html, title }),
  });
  if (!res.ok) throw new Error(await res.text());
  return (await res.json()).url;
}

Expiry

Every Folio expires. The default is 7 days. Signed-in users can choose 7, 14, 30, or 90 days at publish time — either via the UI or the API.

curl — 30-day expiry
curl -X POST https://folio.ac/api/publish \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Quarterly Report",
    "html": "...",
    "expiresInDays": 30
  }'
Valid values: 7, 14, 30, 90. Any other value falls back to 7 days. Only signed-in users can extend beyond 7 days.

Custom URLs

Claim a username and publish at folio.ac/you/r/my-report instead of a random slug. Custom URLs are memorable, link-shareable, and show up in your dashboard.

  1. Sign in with Google.
  2. Open the publish form, expand Options, and claim your handle (e.g. alice).
  3. Enter a custom slug for the report (e.g. q2-revenue).
  4. Your Folio publishes at folio.ac/alice/r/q2-revenue.
curl — custom URL
curl -X POST https://folio.ac/api/publish \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "title": "Q2 Revenue",
    "html": "...",
    "customSlug": "q2-revenue"
  }'

# Response (if username is 'alice'):
# { "url": "https://folio.ac/alice/r/q2-revenue" }
Custom slugs must be lowercase letters, numbers, and hyphens (1–64 chars). They are scoped to your username — two different users can each have a q4-report.

Password protection

Add a password field to gate a Folio. Viewers enter it once and get a session cookie — they won't be prompted again on the same device.

curl — password-protected
curl -X POST https://folio.ac/api/publish \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Internal Draft",
    "html": "...",
    "password": "review2025"
  }'
python
import requests

r = requests.post("https://folio.ac/api/publish", json={
    "title": "Internal Draft",
    "html": render_report(),
    "password": "review2025",
})
print(r.json()["url"])
The password is hashed server-side with SHA-256. It is never stored in plaintext. Folio does not offer password recovery — share it out-of-band.

Comments

Any signed-in viewer can click anywhere on a Folio to drop a numbered comment pin — Figma-style. Pins are anchored to the document position, not the scroll position, so they stay in place even on long reports.

Place a comment
Click "Add comment" in the sidebar, then click anywhere on the report. A + pin appears at that position.
View comments
Click the Comments button (bottom-right). The sidebar lists all comments with author and timestamp.
Numbered pins
Each comment gets a number. Click a pin on the report or its entry in the sidebar to highlight both.
Who can comment?
Any signed-in user with the link. The report owner can see all comments in the dashboard.

Dashboard & analytics

Sign in and go to /dashboard to see every Folio you've published. Click any row to open its detail view.

Views
Total views, unique countries, average time-on-page per Folio.
Viewers
Each view logged with device, browser, OS, city, country, and duration.
Comments
Total comment count per Folio. Click through to see the full thread.
To delete a Folio, open its detail page and click Delete folio (bottom of the header area). Deletion is permanent — views and comments are removed too.

Python SDK

install
pip install folio-sdk
usage
import folio

# Set your API key once — copy it from folio.ac/dashboard
folio.configure("fol_your_key_here")
# Or set the FOLIO_API_KEY environment variable instead:
# export FOLIO_API_KEY=fol_your_key_here

url = folio.publish(html, title="Q2 Analysis")
print(url)  # → https://folio.ac/r/abc123
all options
url = folio.publish(
    html,                        # required — complete HTML string
    title="Q2 Revenue",          # shown in header and dashboard
    expires_in_days=30,          # 7 | 14 | 30 | 90  (default: 7)
    password="internal-only",    # optional gate for viewers
    custom_slug="q2-revenue",    # → folio.ac/you/r/q2-revenue
)
with an LLM
import anthropic
import folio

folio.configure("fol_your_key_here")
client = anthropic.Anthropic()

msg = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=8096,
    messages=[{
        "role": "user",
        "content": "Analyse this data and return a complete HTML report with charts.",
    }],
)

url = folio.publish(msg.content[0].text, title="Data analysis", expires_in_days=30)
print(f"Report: {url}")
The SDK reads FOLIO_API_KEY from the environment automatically — no configure() call needed if the variable is set. You can also pass api_key="fol_..." directly to publish() to override per-call.

All languages & runtimes

Folio speaks plain HTTP. Use the official Python SDK or copy a snippet for your environment.

python — install the SDK
pip install folio-sdk

import folio
folio.configure("fol_your_key_here")   # or set FOLIO_API_KEY env var

url = folio.publish(html, title="Q2 Analysis", expires_in_days=30)
print(url)  # → https://folio.ac/r/abc123
python — raw requests
import requests

def publish(html: str, title: str = "", password: str = "") -> str:
    payload = {"html": html, "title": title}
    if password:
        payload["password"] = password
    r = requests.post(
        "https://folio.ac/api/publish",
        json=payload,
        headers={"x-api-key": "YOUR_KEY"},  # optional
    )
    r.raise_for_status()
    return r.json()["url"]

url = publish(html=open("report.html").read(), title="Weekly digest")
print(url)
javascript / node
const publishFolio = async (html, title = "") => {
  const res = await fetch("https://folio.ac/api/publish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ html, title }),
  });
  const data = await res.json();
  return data.url;
};

const url = await publishFolio(html, "Sprint summary");
console.log(url);
typescript
interface FolioOptions {
  html: string;
  title?: string;
  password?: string;
  customSlug?: string;
  expiresInDays?: 7 | 14 | 30 | 90;
}

async function publishFolio(opts: FolioOptions): Promise<string> {
  const res = await fetch("https://folio.ac/api/publish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(opts),
  });
  if (!res.ok) throw new Error(await res.text());
  return (await res.json()).url;
}
bash — upload .html file
curl -X POST https://folio.ac/api/publish \
  -F "file=@report.html" \
  -F "title=Monthly report"

# multipart/form-data upload — no JSON needed
ruby
require "net/http"
require "json"

uri = URI("https://folio.ac/api/publish")
res = Net::HTTP.post(uri,
  { html: File.read("report.html"), title: "Analysis" }.to_json,
  "Content-Type" => "application/json"
)
puts JSON.parse(res.body)["url"]
go
package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
  "os"
)

func publish(html, title string) (string, error) {
  body, _ := json.Marshal(map[string]string{"html": html, "title": title})
  resp, err := http.Post("https://folio.ac/api/publish", "application/json", bytes.NewReader(body))
  if err != nil {
    return "", err
  }
  defer resp.Body.Close()
  var out struct{ URL string `json:"url"` }
  json.NewDecoder(resp.Body).Decode(&out)
  return out.URL, nil
}

func main() {
  html, _ := os.ReadFile("report.html")
  url, _ := publish(string(html), "Go report")
  fmt.Println(url)
}

AI & LLM integrations

Any model that can generate HTML and make HTTP requests can publish to Folio. Here are patterns for the most common setups.

Claude (API)

python — claude + folio
import anthropic, requests

client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=8096,
    messages=[{
        "role": "user",
        "content": "Analyse this dataset and return a complete HTML report with charts.",
    }],
)

html = message.content[0].text  # Claude returns the HTML

url = requests.post("https://folio.ac/api/publish", json={
    "title": "Dataset analysis",
    "html": html,
    "expiresInDays": 30,
}).json()["url"]

print(f"Report live at: {url}")

Claude Code / Cursor (MCP)

Add Folio as an MCP server and any Claude Code or Cursor session can publish reports directly — no extra code needed. Get your API key from the dashboard.

~/.claude.json (Claude Code)
{
  "mcpServers": {
    "folio": {
      "type": "http",
      "url": "https://folio.ac/mcp",
      "headers": {
        "Authorization": "Bearer fol_your_key_here"
      }
    }
  }
}

For Cursor, add the same block under Settings → MCP Servers. Once connected, tell Claude to publish and it will call the tool automatically:

prompt
"Analyse this CSV and publish a report with charts."
# → Claude generates HTML, calls publish_report, returns:
# Published. Share this URL: https://folio.ac/r/abc123

ChatGPT — Custom GPT Action (recommended)

Important: ChatGPT's built-in code executor runs in a sandboxed environment that cannot reach external URLs — so asking ChatGPT to “run curl” or call the API from Python code will fail with a DNS error. The correct approach is a Custom GPT Action, which runs through OpenAI's servers and can reach any public HTTPS endpoint.

Set up once, then any ChatGPT conversation in that GPT can publish directly to Folio:

  1. Go to chatgpt.com → Explore GPTs → Create.
  2. Open the Configure tab, scroll to Actions, click Create new action.
  3. In the Schema field, click Import from URL and paste:
    https://folio.ac/api/openapi
  4. Under Authentication, choose API Key, header name x-api-key, and paste your key from the dashboard.
  5. Save and start chatting. Tell the GPT to generate and publish a report — it will call the action and return a folio.ac link.
example system prompt for the Custom GPT
You are a data analyst. When asked to create a report or visualisation:
1. Generate a complete, self-contained HTML page with inline CSS and Chart.js from CDN.
2. Call the publishReport action with that HTML and a descriptive title.
3. Return the URL from the response to the user.

Python / server-side (no code executor)

python — openai api + folio
from openai import OpenAI
import requests

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{
        "role": "user",
        "content": "Return ONLY a complete HTML page with a Chart.js bar chart of this data: ...",
    }],
)

html = response.choices[0].message.content

url = requests.post("https://folio.ac/api/publish", json={
    "title": "Sales dashboard",
    "html": html,
}, headers={"x-api-key": "fol_your_key_here"}).json()["url"]

print(url)  # → https://folio.ac/r/abc123

Google Gemini

python — gemini + folio
import google.generativeai as genai
import requests

genai.configure(api_key="YOUR_KEY")
model = genai.GenerativeModel("gemini-1.5-pro")

response = model.generate_content(
    "Write a complete HTML report with embedded Chart.js visualisations for this data: ..."
)

html = response.text

url = requests.post("https://folio.ac/api/publish", json={
    "title": "Gemini report",
    "html": html,
    "expiresInDays": 14,
}).json()["url"]

print(url)

LangChain / LangGraph agents

python — langchain tool
from langchain.tools import tool
import requests

@tool
def publish_report(html: str, title: str = "") -> str:
    """Publish an HTML report and return a shareable URL."""
    r = requests.post("https://folio.ac/api/publish", json={"html": html, "title": title})
    return r.json()["url"]

# Add to your agent's tool list:
# agent = create_react_agent(llm, tools=[publish_report, ...])

Any agent framework

The pattern is always the same: 1) ask the model to return HTML, 2) POST it to https://folio.ac/api/publish, 3) share the URL. Any LLM that can generate HTML works — Mistral, Llama, Cohere, local models via Ollama, etc.

API reference

One endpoint. One method. JSON or multipart.

POST/api/publish
FieldTypeRequired
htmlstringYes
titlestringNo
passwordstringNo
customSlugstringNo
expiresInDays7|14|30|90No

Response

{
  "url":  "https://folio.ac/r/abc123",   // canonical shareable URL
  "slug": "abc123"                        // random internal slug
}

Authentication

Pass your personal API key (copy it from the dashboard) as x-api-key: fol_… or Authorization: Bearer fol_…. Reports published with your key appear in your dashboard alongside UI-published Folios.

If the server has a PUBLISH_API_KEY environment variable set, that key acts as a server-wide gate — only requests that pass it (or use a valid user key) are accepted.

Multipart upload

curl — file upload
curl -X POST https://folio.ac/api/publish \
  -F "file=@report.html" \
  -F "title=My report" \
  -F "password=secret"

File must have a .html extension. The title field defaults to the filename if omitted.

Questions? Open an issue on GitHub.

Publish a Folio →