AI Publishing Operational Manual

Publishing governed artifacts from your AI agent

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.

1. Setup

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.

Claude Desktop / Claude Code

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".

Perplexity

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 CLI

codex mcp add publishwithai --transport http "https://publishwith.ai/mcp" \
  -- --header "Authorization: Bearer YOUR_API_KEY"

Terminal CLI (publishwithai)

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

Cursor / VS Code / mcp.json

{
  "publishwithai": {
    "type": "http",
    "url": "https://publishwith.ai/mcp",
    "headers": { "Authorization": "Bearer YOUR_API_KEY" }
  }
}

ChatGPT Custom Actions

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.

ℹ️
The tool exposes a single publishwithai action-based tool. All operations use action="..." as the first parameter.

2. Static artifacts — HTML & Markdown

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).

Minimal HTML report

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 }

Markdown report

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.

Multi-file artifact

publish_static(artifact_id="abc123", files={
  "index.html": "<!DOCTYPE html>...",
  "style.css":  "body { font-family: sans-serif; }",
  "data.json":  '{"revenue": 1200000}'
})

Multi-page site (paths and sub-pages)

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}/):

Editing an existing artifact

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.

External scripts and CDN allowlist

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.

3. Data-backed artifacts — SQL + Liquid templates

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 }

Manifest schema

FieldRequiredDescription
kindYesMust be mcp-html-artifact
versionYesMust be 1
routes[].pathYesURL path, supports {param} placeholders
routes[].templateGET onlyLiquid template filename
routes[].methodNoPOST for mutation routes (default: GET)
routes[].queriesNoNamed SQL SELECT queries injected into template context

4. Incremental uploads

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": "..."})
⚠️
Tokens expire after 30 minutes. Starting a new publish_static without a token always creates a new version.
🔀
Avoiding overwrites. When you update an artifact others may also edit, pass 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.

5. Share links

Share links provide token-based access to a specific artifact. Two access levels are available:

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" }

6. All actions reference

ActionDescription
create_artifactCreate a new published artifact. Accepts optional description (max 500 chars). Returns artifact_id and URL.
list_artifactsList all published artifacts. Accepts optional query for case-insensitive search on slug, name, and description. Returns array with description field.
delete_artifactDelete an artifact and all its data.
publish_staticUpload static files (HTML, MD, CSS, JS…). Accepts optional description (updates on re-publish when non-empty). Returns URL and version.
publish_dynamicPublish a data-backed artifact with manifest + Liquid templates. Accepts optional description.
validate_artifactValidate a manifest before publishing. Returns errors if any.
execute_sqlRun SQL on an artifact's isolated SQLite database.
list_tablesList all tables and columns in an artifact's database.
validate_pythonTeam+. 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_pythonTeam+. 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_pythonTeam+. Run a stored report against its own SQLite and render a fresh served version on demand. Idempotent: identical output is a no_change.
share_withGrant 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_accessDeclarative 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_visibilitySet 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_accessWho CAN access: visibility, owner, and all named grants with provenance and expiry.
list_viewsWho DID access: per-view log with viewer email, path, IP, and timestamp.
revoke_accessRemove a direct grant by principal or grant_id. Reports residual_access if access remains via another path.
set_publishedToggle artifact online/offline. Content kept. published=true requires version>0.
set_ttlSet 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.
previewFetch the rendered HTML of an artifact (dry-run for dynamic APIs).
get_sourceFetch 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_uploadGet 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_userInvite a user to your tenant.
list_groupsList access groups in your tenant.
list_usersList users in your tenant. Accepts optional query for search.
find_peopleResolve 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_groupsList this tenant's audience groups (reusable sets of external contacts) with their contacts.
set_audience_groupCreate or replace an audience group by name with contacts (array of {display_name, email}). Membership is fully replaced each call.
delete_audience_groupDelete an audience group by audience_group_id.
searchFull-text content search across artifact body text. Returns bm25-ranked results with highlighted snippets, ACL-filtered. Team/Enterprise only.
docsFetch inline documentation. Use topic=X for a specific section.

Report access requirements

ℹ️
Tier: team or enterprise — free tier returns FORBIDDEN_TIER.
Tenant opt-in: a tenant admin must enable "Python reports" in Settings → Security — until then all report actions return REPORTS_NOT_OPTED_IN. (validate_python is gated on tier + opt-in but not role.)
Role: editor or tenant_admin may author and run reports — a viewer gets NOT_AUTHORIZED.
Credentialed reports: if the manifest declares 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.
Connections: defined and granted to groups by a tenant admin in the admin UI only — there is no MCP/REST action to manage them.

Report example

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).

7. Error handling

Every action returns a hint field on error. Always surface it to the user or use it to self-correct before retrying.

💡
Call action="docs" to fetch the latest in-context documentation at any time. Use topic="manifest", topic="sql", or topic="share" for filtered sections.

Common errors

ErrorCauseFix
artifact not foundWrong artifact_idCall list_artifacts to get valid IDs
token expiredUpload session > 30 minStart new session with publish_static (no token)
mode mismatchpublish_static on a dynamic artifactUse publish_dynamic
quota exceededPlan limit reachedDelete unused artifacts or upgrade plan
manifest invalidYAML/schema errorUse validate_artifact first to get field-level errors
Markdown clobbered on editEdited an index.md artifact from its rendered URL or preview (both HTML) and republished index.htmlFetch the real source with get_source, edit those files, republish every file it returned