Complete reference for Claude, Codex, ChatGPT, and any MCP-compatible client. Use the publishwith.ai MCP tool to create, update, and manage governed reports, dashboards, and data-backed artifacts.
Add the MCP server to your client once. Use OAuth when supported, or a personal API key from Connect for clients that cannot complete OAuth.
Tenant admins can create self-service OAuth applications from the Connect page. Two types are available: confidential (client ID and secret, suitable for ChatGPT and server apps) and public/PKCE (no secret, suitable for native MCP clients such as Cursor and Claude Desktop that authenticate over a loopback redirect). OAuth tokens issued to self-service clients renew silently via refresh tokens, so users are not prompted to log in again during an active session.
Recommended path: Claude signs in with OAuth, so there is no API key to manage. In Claude Desktop, open Settings → Connectors → Add custom connector and paste https://publishwith.ai/mcp. From the terminal:
claude mcp add publishwithai --transport http "https://publishwith.ai/mcp"
For headless or CI setups that cannot complete OAuth, append a personal API key: -- --header "Authorization: Bearer YOUR_API_KEY".
A natural fit: Perplexity Labs generates HTML reports and dashboards, and a custom MCP connector publishes them directly. On paid plans (Pro, Max, Enterprise), open Settings → Connectors → + Custom connector → Remote, paste https://publishwith.ai/mcp, and sign in with OAuth.
codex mcp add publishwithai --transport http "https://publishwith.ai/mcp" \ -- --header "Authorization: Bearer YOUR_API_KEY"
For terminal-resident agents with no MCP config, the publishwithai CLI is a thin Bearer-auth client over the REST API. Its one capability beyond MCP/REST: it reads a local folder directly instead of inlining every file into JSON.
go install github.com/publishwith-ai/publishwithai-cli/cmd/publishwithai@latest export PUBLISHWITHAI_API_KEY=YOUR_API_KEY publishwithai publish ./my-report # upsert a folder of HTML/Markdown/assets publishwithai list # list your artifacts publishwithai share my-report # create a share link
{
"publishwithai": {
"type": "http",
"url": "https://publishwith.ai/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
ChatGPT Custom GPTs use a REST API instead of MCP. In the GPT builder, add a Custom Action using the publishwith.ai OpenAPI spec with Bearer auth (your personal API key). Enterprise workspaces may restrict or disable API keys. The API exposes POST /api/v1/publish, GET /api/v1/artifacts, GET /api/v1/artifacts/{slug}/source (the REST equivalent of get_source; add ?version=N for an older version), and POST /api/v1/artifacts/{slug}/share. For the full walkthrough, including the official GPT and OAuth, see the ChatGPT setup guide.
publishwithai action-based tool. All operations use action="..." as the first parameter.Static artifacts serve files directly. Supported formats: HTML, Markdown (.md), CSS, JS, JSON, images, and any text format. Markdown files are automatically rendered to HTML with GFM support (tables, code blocks, task lists).
create_artifact(name="my-report", description="Q3 revenue summary")
→ { artifact_id: "abc123", url: "https://publishwith.ai/a/my-report/" }
publish_static(artifact_id="abc123", files={
"index.html": "<h1>Q3 Revenue</h1><p>$1.2M</p>"
})
→ { url: "https://publishwith.ai/a/my-report/", version: 1 }
publish_static(artifact_id="abc123", files={
"index.md": "# Q3 Report\n\n| Month | Revenue |\n|---|---|\n| Jul | $380K |\n| Aug | $420K |\n"
})
Markdown is rendered server-side using GitHub Flavored Markdown. Tables, fenced code blocks, task lists, and inline HTML are all supported.
publish_static(artifact_id="abc123", files={
"index.html": "<!DOCTYPE html>...",
"style.css": "body { font-family: sans-serif; }",
"data.json": '{"revenue": 1200000}'
})
The files keys are paths, so one artifact can be a whole mini-site: an index.html plus sub-pages and nested folders.
publish_static(artifact_id="abc123", files={
"index.html": "<a href='about.html'>About</a> <a href='docs/guide.html'>Guide</a>",
"about.html": "...",
"docs/guide.html": "...",
"docs/api.md": "# API",
"style.css": "..."
})
How paths resolve (identically at /a/{slug}/, /t/{tenant}/a/{slug}/, and share links /p/{token}/):
/ serves index.html, falling back to index.md. Only the root auto-resolves an index./about.html and /docs/guide.html serve those exact files; nested folders are preserved. .md files render to HTML at any path, not just the index./docs/, /about/) returns 404 — sub-folders have no directory-index. Link to an explicit file such as docs/index.html.href="about.html", not /about.html) so they resolve under every URL prefix.To change an artifact you already published, fetch its real source with get_source, edit those files, then publish them back. Do not use the published URL or preview output as your edit source — both return rendered HTML, so editing an index.md artifact that way and republishing index.html silently clobbers the original Markdown.
get_source(artifact_id="abc123")
→ { artifact_id: "abc123", mode: "static", version: 3,
files: { "index.md": "# Q3 Report\n\n...", "style.css": "..." } }
# Edit files in place, then send back EVERY file get_source returned
publish_static(artifact_id="abc123", files={
"index.md": "# Q3 Report (revised)\n\n...",
"style.css": "..."
})
publish_static replaces the whole version, so send back every file get_source returned — any file you omit is dropped. Pass version=N to fetch an older version's source; omit it for the current published version. Binary assets come back base64-encoded, text files inline as UTF-8.Published artifacts can use remote scripts and styles only from https://cdn.jsdelivr.net/... by default. Use jsDelivr for libraries such as Chart.js. Do not use unpkg, cdnjs, Google CDN, or arbitrary external script/style origins unless your workspace administrator has approved them. Team workspaces can use an admin-managed CDN allowlist; Enterprise workspaces can define a advanced CDN dependency policy.
If a library is not available from jsDelivr, publish it as a local artifact file such as vendor/library.js or vendor/library.css.
Data-backed artifacts have an isolated SQLite database and Liquid templates that render data server-side. Routes can have parameters, queries, and POST handlers.
create_artifact(name="inventory", mode="dynamic", description="Product inventory tracker")
→ { artifact_id: "xyz789" }
execute_sql(artifact_id="xyz789",
sql="CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)")
execute_sql(artifact_id="xyz789",
sql="INSERT INTO items (name, qty) VALUES ('Widget A', 42), ('Widget B', 7)")
publish_dynamic(artifact_id="xyz789",
manifest="""
kind: mcp-html-artifact
version: 1
routes:
- path: /
template: index.html
queries:
items:
sql: "SELECT * FROM items ORDER BY name"
""",
templates={
"index.html": """
<!DOCTYPE html>
<html><body>
<h1>Inventory</h1>
<ul>{% for item in items %}
<li>{{ item.name }} — {{ item.qty }} units</li>
{% endfor %}</ul>
</body></html>
"""
}
)
→ { url: "...", version: 1, routes: 1 }
| Field | Required | Description |
|---|---|---|
kind | Yes | Must be mcp-html-artifact |
version | Yes | Must be 1 |
routes[].path | Yes | URL path, supports {param} placeholders |
routes[].template | GET only | Liquid template filename |
routes[].method | No | POST for mutation routes (default: GET) |
routes[].queries | No | Named SQL SELECT queries injected into template context |
For large artifacts, upload files in batches using the token returned by the first call. All calls with the same token update the same version atomically.
# Start a new version — no token
publish_static(artifact_id="abc123", files={"index.html": "..."})
→ { token: "tok_abc...", version: 2 }
# Add more files to the same version
publish_static(artifact_id="abc123", token="tok_abc...", files={"style.css": "..."})
publish_static(artifact_id="abc123", token="tok_abc...", files={"app.js": "..."})
publish_static without a token always creates a new version.base_version (the version you last read). If a different author published a newer version since, the call returns VERSION_CHANGED with the current_version and a confirmation_token instead of silently overwriting their work. Re-call publish_static with the same files and that token to overwrite. Same-author updates need no base_version.Share links provide token-based access to a specific artifact. Two access levels are available:
recipient_email can view. They must log in with that email. If they don't have an account, they are prompted to register.When recipient_email is set, a notification email is always sent regardless of access level. Access is tracked (anonymised — no IP or user agent stored).
To share with a whole curated contact list at once, set audience_group (an id from list_audience_groups). This fans out one restricted link per contact, emails each, and returns links[] instead of a single share_url. It forces restricted access and is mutually exclusive with recipient_email.
# Grant access to an external contact — produces a token share link (shown once)
share_with(artifact_id="abc123", principal={"type":"external_email","value":"bob@co.com"})
→ { result_code: "created", resolved_lane: "external", share_url: "https://publishwith.ai/p/abc...", ... }
# Grant access to a tenant colleague — no token link needed
share_with(artifact_id="abc123", principal={"type":"tenant_user","value":"alice@co.com"}, access="edit")
→ { result_code: "created", resolved_lane: "internal", opens_via: "https://publishwith.ai/a/abc123/" }
# Fan out to an audience group — one link per contact, each emailed
share_with(artifact_id="abc123", principal={"type":"audience","value":"aud_..."})
→ { result_code: "created", resolved_lane: "external", ... }
# Re-sharing returns deduplicated instead of minting a duplicate
share_with(artifact_id="abc123", principal={"type":"external_email","value":"bob@co.com"})
→ { result_code: "deduplicated", ... }
# See who can access and who has accessed
list_access(artifact_id="abc123")
→ { visibility: "private", owner: "...", access: [{ principal_type: "external_email", principal: "bob@co.com", access_level: "view", expires_at: "..." }, ...] }
list_views(artifact_id="abc123")
→ { views: [{ viewer_email: "bob@co.com", path: "/", accessed_at: "..." }, ...] }
# Revoke a specific grant
revoke_access(artifact_id="abc123", principal={"type":"external_email","value":"bob@co.com"})
→ { status: "revoked", residual_access: "none" }
| Action | Description |
|---|---|
create_artifact | Create a new published artifact. Accepts optional description (max 500 chars). Returns artifact_id and URL. |
list_artifacts | List all published artifacts. Accepts optional query for case-insensitive search on slug, name, and description. Returns array with description field. |
delete_artifact | Delete an artifact and all its data. |
publish_static | Upload static files (HTML, MD, CSS, JS…). Accepts optional description (updates on re-publish when non-empty). Returns URL and version. |
publish_dynamic | Publish a data-backed artifact with manifest + Liquid templates. Accepts optional description. |
validate_artifact | Validate a manifest before publishing. Returns errors if any. |
execute_sql | Run SQL on an artifact's isolated SQLite database. |
list_tables | List all tables and columns in an artifact's database. |
validate_python | Team+. Synchronously validate a Python report script without running it. Returns what is supported, what is missing and the in-sandbox alternative, the libraries you may declare, and granted_connections (egress your groups may use). Call before publish_python. |
publish_python | Team+. Store a Python report (report.py + report.yaml) as a python-mode artifact. Linted first; unsupported imports rejected. A schedule: registers a recurring refresh. |
run_python | Team+. Run a stored report against its own SQLite and render a fresh served version on demand. Idempotent: identical output is a no_change. |
share_with | Grant ONE principal access (tenant_user/tenant_group/external_email/audience). Additive; never changes visibility. External grants produce a token share link (shown once). Returns result_code, resolved_lane, share_url (external only). |
set_access | Declarative bulk access: set owners/editors/viewers/groups in one call. replace=true revokes grants not in the new set. owner_only=true resets to owner-only + private. |
set_visibility | Set artifact visibility: private (default), tenant (any member), or public (anyone, no login). Named grants are not removed when going private. Requires authorization for public. |
list_access | Who CAN access: visibility, owner, and all named grants with provenance and expiry. |
list_views | Who DID access: per-view log with viewer email, path, IP, and timestamp. |
revoke_access | Remove a direct grant by principal or grant_id. Reports residual_access if access remains via another path. |
set_published | Toggle artifact online/offline. Content kept. published=true requires version>0. |
set_ttl | Set a time limit (ttl_days). After it, the artifact auto-goes-offline behind a wall for public visitors; owner and MCP/REST keep access. ttl_days=0 removes it. Max 365. |
preview | Fetch the rendered HTML of an artifact (dry-run for dynamic APIs). |
get_source | Fetch the RAW source files of an existing artifact to edit and republish. Accepts optional version (omit for current). Returns {artifact_id, mode, version, files{}} in the same shape publish_static accepts. Do NOT scrape the published URL or preview to edit — both return rendered HTML. |
request_upload | Get an out-of-band upload token to add binary files (images, fonts) > 64 KiB to an artifact without inlining them through the model. Returns upload_token, upload_url, upload_expires_at (5-minute TTL), and a ready-to-paste curl command. publish_static also returns these fields so you can upload binaries right after publishing. Use when publish_static returns USE_UPLOAD. |
create_user | Invite a user to your tenant. |
list_groups | List access groups in your tenant. |
list_users | List users in your tenant. Accepts optional query for search. |
find_people | Resolve a recipient by name or email against your org directory (real users + provisioned IdP people, e.g. "Federico Coletto") to share with the right person. Params: query, optional limit. |
list_audience_groups | List this tenant's audience groups (reusable sets of external contacts) with their contacts. |
set_audience_group | Create or replace an audience group by name with contacts (array of {display_name, email}). Membership is fully replaced each call. |
delete_audience_group | Delete an audience group by audience_group_id. |
search | Full-text content search across artifact body text. Returns bm25-ranked results with highlighted snippets, ACL-filtered. Team/Enterprise only. |
docs | Fetch inline documentation. Use topic=X for a specific section. |
FORBIDDEN_TIER.REPORTS_NOT_OPTED_IN. (validate_python is gated on tier + opt-in but not role.)editor or tenant_admin may author and run reports — a viewer gets NOT_AUTHORIZED.connections:, each one must be granted to one of your tenant groups (an admin may use any) — a non-granted name returns NOT_AUTHORIZED. A db_write: true report is admin-only.A report runs in a hardened per-run CPython-WASM sandbox: no raw sockets or filesystem, a read-only database by default, and strict time, memory, and external-call limits. HTTP is available only through requests routed to admin-defined named connections (the host injects the credential; your script never sees it). Call validate_python to see exactly what is supported and which connections your groups may use.
A minimal report that aggregates the artifact's own SQLite and renders HTML. Pass report.py as script and report.yaml as manifest:
# report.py
import db
rows = db.query("SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC")
html = "<h1>Sales by region</h1><ul>" + "".join(f"<li>{r['region']}: {r['total']}</li>" for r in rows) + "</ul>"
write_output(html, "text/html")
# report.yaml
output_type: html
schedule: 1d # refresh daily; omit for on-demand only
# db_write: true # only if the script calls db.execute(); admin-only
# connections: [crm] # named egress targets, granted to your groups by an admin
Always call validate_python first: it lints the script and lists your granted_connections without using a run slot. Then publish_python, then run_python (or let the schedule refresh it).
Every action returns a hint field on error. Always surface it to the user or use it to self-correct before retrying.
action="docs" to fetch the latest in-context documentation at any time. Use topic="manifest", topic="sql", or topic="share" for filtered sections.| Error | Cause | Fix |
|---|---|---|
| artifact not found | Wrong artifact_id | Call list_artifacts to get valid IDs |
| token expired | Upload session > 30 min | Start new session with publish_static (no token) |
| mode mismatch | publish_static on a dynamic artifact | Use publish_dynamic |
| quota exceeded | Plan limit reached | Delete unused artifacts or upgrade plan |
| manifest invalid | YAML/schema error | Use validate_artifact first to get field-level errors |
| Markdown clobbered on edit | Edited an index.md artifact from its rendered URL or preview (both HTML) and republished index.html | Fetch the real source with get_source, edit those files, republish every file it returned |