Publishing HTML to SharePoint (OneUp HTML viewer)
SharePoint already hosts HTML: save an .html file into OneDrive or SharePoint and
open it, and it renders in the OneUp HTML viewer — a sandboxed <iframe> under a
strict Content-Security-Policy. There is no separate hosting service to provision;
publishing is just writing an .html file to the user's drive as the signed-in
user, then opening the file's webUrl.
This skill teaches an agent two things:
- Author HTML that actually renders — the OneUp CSP + sandbox fail silently, so non-compliant HTML shows up blank or half-broken with no error. Follow the contract below and it just works.
- Publish it — upload the file with Microsoft Graph (primary) or the
SharePoint Vroom API (
_api/v2.0, for agents already holding a SharePoint token), then hand the user the returnedwebUrl.
The canonical, always-current copy of the authoring contract lives at your tenant's
discovery endpoint: https://<your-sharepoint-host>/_html (human page),
/_html/llms.txt (agent text), and /_html/help.json (structured). When in
doubt, fetch /_html/llms.txt — it is generated from the live viewer source.
⚠️ Before you publish: you are writing to the user's real drive
Publishing with this skill creates or overwrites a file in the user's own OneDrive / SharePoint, authenticated as that user. It is not a throwaway sandbox.
- Nothing becomes public automatically. The file is private to the user until they explicitly share it. But it is a real document in their drive.
- You MUST get the user's explicit confirmation before every upload and every overwrite. Show them the exact target (drive, folder, filename) and whether it creates a new file or replaces an existing one, and wait for a yes. See Confirm before writing.
- Overwrite is destructive.
PUT .../contentto an existing path replaces the file's contents. Confirm the path and that overwrite is intended.
The authoring contract (make HTML render in OneUp)
Your HTML runs inside <iframe sandbox="allow-scripts"> under a strict CSP that fails
silently. Follow these rules or the page renders blank/partial with no error.
Content-Security-Policy applied to your HTML:
default-src 'none';
script-src 'unsafe-inline' 'unsafe-eval';
style-src 'unsafe-inline';
img-src data: blob:;
font-src data: blob:;
connect-src 'none';
worker-src 'none';
object-src 'none';
base-uri 'none';
form-action 'none';
The wrapper iframe additionally applies frame-src 'none'.
Non-negotiable rules:
- No external resources. No
<script src>,<link href>,<img src=https://…>,@import,url(https://…), remote fonts, or CDNs. Inline everything or usedata:/blob:URIs. - All script must be inline
<script>blocks. No module imports, noimport(), no<script src>. - All styling must be inline
<style>orstyle="…". No<link rel="stylesheet">. - No network at runtime —
fetch,XMLHttpRequest,WebSocket,sendBeacon,EventSourceare all blocked (connect-src 'none'). To read SharePoint file data at view time, use the LiveData manifest (below) — it is the only sanctioned data path. - No storage primitives —
localStorage,sessionStorage,indexedDB,document.cookie(opaque-origin sandbox). For cross-reload state, setlocation.hashdirectly (e.g.location.hash = '#slide=3'). Do not usehistory.pushState/replaceState— the sandboxed document's base URL is inherited from the wrapper'sblob:origin, so anyurlargument (even a bare#hash) throwsSecurityError. - No top-level navigation, popups, form submission, workers, or plugins.
alert/confirm/promptare no-ops. - Inline
on*=handlers work (they're rewritten to nonced scripts) but are an XSS sink if they template untrusted data — preferaddEventListenerfor computed/user/fetched data. - Save as UTF-8 (the file-creation APIs below write UTF-8; add a BOM if authoring by hand and non-ASCII renders wrong).
Links: <a href> opens only for same-tenant SharePoint hosts or the external
allowlist; javascript: / vbscript: / data: hrefs are rejected.
Live SharePoint data (the only runtime data source)
Runtime fetch is blocked. To show live SharePoint file content, embed a LiveData
manifest — the host prefetches each item server-side with the viewer's credentials and
injects the results before your script runs:
<script type="application/json" class="ka-livedata-manifest">
[{ "spItemUrl": "https://contoso.sharepoint.com/_api/v2.0/drives/{driveId}/items/{itemId}", "fileName": "data.csv" }]
</script>
Before your script runs, the host sets window.__LD_RESULTS__, keyed by the exact spItemUrl.
Each value is { format, content, error?, metadata } — read content (a string) and check error.
Non-obvious but essential:
- Always include
fileName(with extension) in each item — the host picks thecsv/xlsx-sheets/textdecoder from it. Without it an.xlsxcomes back unreadable and a.csvis not parsed as csv. spItemUrlmust contain/drives/{driveId}/items/{itemId}(the origin is ignored, rebuilt from the viewer session). Get those ids by resolving the file:GET /shares/{u!token}/driveItem→parentReference.driveId+id.- Refresh RE-LOADS your iframe with fresh data —
postMessage({type:'ka-html-viewer-refresh'}, '*')(debounced 5s). There is no inbound "updated" event to listen for. - Treat the content as untrusted — build DOM with
textContent, neverinnerHTMLit. - Caps: 32 items/page (over-cap drops all results), 50 MB/item; formats: text, csv, xlsx-sheets, rows.
SharePoint lists — a manifest item can also point at a list (kind:"list" with webUrl + listId,
plus optional select of INTERNAL column names). The host renders it server-side and injects it as
format:"rows" — content is a JSON string { headers, rows } (already flattened; up to 100,000 rows,
truncation sets a non-blocking warning). Resolve listId + internal column names at authoring time via
GET {webUrl}/_api/web/lists/getbytitle('{Name}')/fields?$select=InternalName,Title,TypeAsString (or Graph
/sites/{site-id}/lists/{list-id}/columns). Internal names can differ from the display title (e.g. "City"
→ Ciity). See references/live-data.md.
Full protocol + a complete worked dashboard: references/live-data.md
(also mirrored in your tenant's /_html/llms.txt). Table-only starter:
assets/templates/livedata-dashboard.html.
Don't have HTML yet? Start from a template
If the user wants to publish something but has no HTML, use a ready-made, CSP-compliant
template from assets/templates/ instead of hand-writing:
| Template | Use it when the user wants to… |
|---|---|
dashboard.html |
…build a self-contained metrics/status dashboard (no external data). |
livedata-dashboard.html |
…render live SharePoint file data (csv/xlsx/text) via the LiveData manifest. |
livedata-list-dashboard.html |
…render a live SharePoint list (kind:"list" → format:"rows") — KPIs, category charts, table. |
Read the chosen file, replace its <!-- REPLACE: … --> placeholders with the user's real
content, verify it against the contract above, then publish.
Step 1 — Author (or fill in) compliant HTML
Produce a single self-contained .html document that obeys the contract. Everything inline;
no external references; LiveData manifest if it needs SharePoint data.
Step 2 — Get an access token
Publishing is authenticated as the user. You need an OAuth 2.0 bearer token (Azure AD).
Primary — Microsoft Graph:
- Scope (delegated):
Files.ReadWrite(write to the user's own drive). UseFiles.ReadWrite.AllorSites.ReadWrite.Allto write to other libraries/sites the user can access. - Acquire it with the user's identity via MSAL (interactive, device-code, or an
existing signed-in session). Do not invent or hardcode tokens. If you don't already
have a Graph token, run an MSAL flow — see
references/api.md.
Alternative — SharePoint Vroom (use when the agent is already onboarded with a
SharePoint OAuth token, e.g. an add-in / SPFx / on-behalf-of flow): a token whose audience
is the SharePoint resource (https://{tenant}.sharepoint.com or -my.sharepoint.com),
scope AllSites.Write / MyFiles.Write. Same driveItem calls, different base URL.
Full token flows, scopes, and consent are in references/api.md.
Step 3 — Confirm, then publish
Confirm before writing
First preflight the HTML — run scripts/preflight.sh <file>; it greps for forbidden APIs and checks
LiveData manifest items (file items need spItemUrl + fileName; kind:"list" items need spItemUrl +
webUrl + listId) ("renders blank with no error" is the top failure). Then list the destination
folder (GET …/children) to detect a name collision, so you can tell the user accurately whether this
is a create or an overwrite.
Before the upload call, show the user exactly what will happen and get a yes:
About to publish to SharePoint/OneDrive:
Account: user@contoso.com
Location: OneDrive › Documents/Agent HTML/
File: sales-dashboard.html
Action: Create new file (or: Overwrite existing file — REPLACES its contents)
Renders at: <webUrl>?web=1 (?web=1 opens the OneUp viewer; the bare webUrl downloads)
Proceed?
Only continue on an explicit yes. For overwrite, make the destructive nature explicit.
Publish via Microsoft Graph (primary)
Simple upload (files ≤ 4 MB — plenty for inlined HTML). New file by path:
PUT https://graph.microsoft.com/v1.0/me/drive/root:/Agent HTML/<name>.html:/content
Authorization: Bearer <token>
Content-Type: text/html
<!DOCTYPE html> … your document …
Response 201 Created (new) / 200 OK (replace) → a driveItem. Read webUrl, then
give the user <webUrl>?web=1 — the ?web=1 opens it in the OneUp viewer; the bare webUrl
downloads the .html file:
{
"id": "01ABC…",
"name": "sales-dashboard.html",
"size": 84213,
"webUrl": "https://contoso-my.sharepoint.com/personal/user_contoso_com/Documents/Agent%20HTML/sales-dashboard.html",
"file": { "mimeType": "text/html" }
}
For files > 4 MB use an upload session (createUploadSession) — see
references/api.md.
Publish via SharePoint Vroom (alternative)
Same shape, SharePoint base URL, SharePoint-audience token. Use this when you already hold a
SharePoint OAuth token (SPFx / add-in / on-behalf-of) or want to target a specific SharePoint host
or site directly. (graph.microsoft.com already routes own-drive uploads to the caller's farm —
including dogfood rings like *.sharepoint-df.com — so Graph alone is usually enough.)
PUT https://{tenant}-my.sharepoint.com/_api/v2.0/me/drive/root:/Agent HTML/<name>.html:/content
Authorization: Bearer <sharepoint-token>
Content-Type: text/html
<!DOCTYPE html> … your document …
Returns the same driveItem (read webUrl). Full details, including site-drive paths
(/_api/v2.0/drives/{driveId}/items/{itemId}/content), in
references/api.md.
Publish into a SharePoint site library (instead of your OneDrive)
Same call against a site drive. With Graph (scope Sites.ReadWrite.All):
PUT https://graph.microsoft.com/v1.0/sites/{site-id}/drive/root:/Agent HTML/<name>.html:/content
or Vroom against the site host:
PUT {siteUrl}/_api/v2.0/drive/root:/Agent HTML/<name>.html:/content
The site drive root is its default document library ("Shared Documents"), so keep the path
root-relative. Resolve {site-id} with GET /sites/{host}:/sites/{path} (or /teams/{path}).
Publish by folder URL — fewest calls (Shares API)
If you only have the destination folder's URL, skip resolving drive/site ids — encode the URL as a sharing token and upload into it directly:
- Encode:
u!+ unpadded base64url of the folder URL (base64, strip=,/→_,+→-). GET /shares/{u!token}/driveItem→ the folder'sid+parentReference.driveId(one call).PUT /drives/{driveId}/items/{folderId}:/<name>.html:/content→ upload.
That's 2 calls, and it works for any OneDrive, site, or library. Details in
references/api.md; ready-made
script: scripts/publish-to-folder.sh.
Step 4 — Open it
Give the user <webUrl>?web=1 (the webUrl from the response with ?web=1 appended). The
?web=1 renders the HTML in the OneUp viewer; the bare webUrl downloads the .html file.
webUrl is stable across updates, so links keep working after you modify.
Updating / modifying an asset you created
To change a published file, overwrite it — same path (or item id), same call:
PUT https://graph.microsoft.com/v1.0/me/drive/items/{item-id}/content
Authorization: Bearer <token>
Content-Type: text/html
<!DOCTYPE html> … updated document …
or by path: PUT /me/drive/root:/Agent HTML/<name>.html:/content.
Overwrite replaces the file's contents — confirm with the user first (show the target
- that it replaces the existing file). The
webUrlstays the same. Vroom is identical with its base URL. To rename/move instead of replace content, usePATCH …/items/{item-id}(seereferences/api.md).
Error codes
| Code | Meaning | Action |
|---|---|---|
401 |
Unauthorized | Missing/expired token, or wrong audience (Graph vs SharePoint). Re-acquire with the right scope. |
403 |
Forbidden | Token lacks write scope (Files.ReadWrite), or the user can't write that location. |
404 |
Not found | Wrong drive/item/path, or the parent folder doesn't exist (create it first). |
413 |
Payload too large | File > 4 MB on simple upload — use an upload session. |
423 |
Locked | The item is checked out / locked. |
507 |
Insufficient storage | The user's drive is full. |
Blank / half-rendered page after opening → an authoring-contract violation (usually an
external resource or a runtime fetch). Re-check the contract.
Reference
references/api.md— full Graph + Vroom endpoint reference: authentication & scopes, token acquisition, request/response schemas, upload sessions, metadata update, and the driveItem shape.references/contract.md— the OneUp authoring contract in full.assets/templates/— ready-made CSP-compliant templates.- Live docs (authoritative, tenant-specific):
https://<your-sharepoint-host>/_html,/_html/llms.txt,/_html/help.json.