Two interchangeable APIs write the .html file. They share the same driveItem shape;
only the base URL and the token audience differ.
| Base URL | Token audience | Typical use | |
|---|---|---|---|
| Microsoft Graph (primary) | https://graph.microsoft.com/v1.0 |
https://graph.microsoft.com |
Any agent with a Graph delegated token. |
| SharePoint Vroom (alternative) | https://{tenant}-my.sharepoint.com/_api/v2.0 (OneDrive) or https://{tenant}.sharepoint.com/{site}/_api/v2.0 (site) |
https://{tenant}.sharepoint.com |
Agents already onboarded with a SharePoint OAuth token (SPFx, add-in, on-behalf-of), or targeting a specific SharePoint host/site directly. |
Everything below shows Graph paths; for Vroom, swap the base URL and use a SharePoint-audience
token — the path suffixes (/me/drive/root:/…:/content, /drives/{id}/items/{id}/content,
/createUploadSession) are identical.
graph.microsoft.comroutes each request to the SPO farm that holds the caller's OneDrive/site based on the token — including dogfood rings such as*.sharepoint-df.com— so Graph "just works" without choosing a host. Only truly separate national clouds (GCC High, DoD, 21Vianet/China) use different Graph/SharePoint endpoints.
All requests are authenticated with an OAuth 2.0 bearer token from Azure AD (Microsoft identity platform, v2.0 endpoint). Requests are made as the signed-in user (delegated).
Authorization: Bearer <access-token>
| Scope | Grants | Use when |
|---|---|---|
Files.ReadWrite |
Read/write the user's own OneDrive | Publishing to the user's OneDrive (default). |
Files.ReadWrite.All |
Read/write all files the user can access | Publishing to a shared library the user can write. |
Sites.ReadWrite.All |
Read/write items in site collections the user can access | Publishing into a SharePoint site library. |
offline_access |
Refresh tokens | Long-running agents that must refresh without re-prompting. |
For Vroom / SharePoint-audience tokens the equivalent delegated scopes are
MyFiles.Write (own OneDrive) and AllSites.Write (sites the user can write).
Least privilege: prefer Files.ReadWrite (own OneDrive) unless the user explicitly wants to
publish into a shared library/site.
Use MSAL with the user's identity. Register (or reuse) an Azure AD app with the delegated scope(s) above and a redirect URI.
Interactive / device-code (CLI agents):
POST https://login.microsoftonline.com/common/oauth2/v2.0/devicecode
client_id=<app-id>&scope=Files.ReadWrite offline_access
# → show the returned user_code + verification_uri to the user, then poll:
POST https://login.microsoftonline.com/common/oauth2/v2.0/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&client_id=<app-id>&device_code=<device_code>
# → { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }
Authorization-code (apps with a redirect URI): standard code → token exchange at the same
/token endpoint with grant_type=authorization_code.
Refresh: grant_type=refresh_token at the /token endpoint (requires offline_access).
Vroom / SharePoint token: request a token whose resource/audience is the SharePoint
host — scope https://{tenant}.sharepoint.com/.default (or AllSites.Write,
MyFiles.Write). SPFx/add-in agents typically already hold this via on-behalf-of.
If you don't already have a valid token, run an MSAL flow and prompt the user to sign in. Never fabricate a token, and never print the token onto the published page.
The simplest publish. Body is the raw HTML bytes; Content-Type: text/html.
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 …Other parents:
PUT /me/drive/items/{parent-id}:/{filename}:/content
PUT /drives/{drive-id}/items/{parent-id}:/{filename}:/content
PUT /sites/{site-id}/drive/items/{parent-id}:/{filename}:/content
PUT /users/{user-id}/drive/items/{parent-id}:/{filename}:/content
Path encoding: in
root:/<path>:addressing, percent-encode spaces and special characters in each path segment (e.g.Agent%20HTML, and a name likeweird%20%5E!%40.html). To avoid encoding entirely, address by ids instead:PUT /drives/{driveId}/items/{parentId}:/{name}:/content.
PUT https://graph.microsoft.com/v1.0/me/drive/items/{item-id}/content
Authorization: Bearer <token>
Content-Type: text/html
<!DOCTYPE html> … updated document …Also: PUT /drives/{drive-id}/items/{item-id}/content,
PUT /sites/{site-id}/drive/items/{item-id}/content.
Delegated: Files.ReadWrite, Files.ReadWrite.All, or Sites.ReadWrite.All.
201 Created (new) or 200 OK (replaced):
{
"id": "01ABCDEF2345",
"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", "hashes": { "quickXorHash": "…" } },
"createdDateTime": "2026-07-18T20:15:00Z",
"lastModifiedDateTime": "2026-07-18T20:15:00Z",
"parentReference": { "driveId": "b!…", "id": "01ABC…", "path": "/drive/root:/Agent HTML" }
}webUrl is the link — hand the user <webUrl>?web=1. The ?web=1 opens it in the OneUp
HTML viewer; the bare webUrl downloads the .html file. It is stable across content updates.
Append ?@microsoft.graph.conflictBehavior=rename|replace|fail to control what happens if the
name exists (default on PUT …:/content is replace). Use rename to avoid overwriting.
For content > 4 MB, create an upload session and PUT byte ranges.
POST https://graph.microsoft.com/v1.0/me/drive/root:/Agent HTML/<name>.html:/createUploadSession
Authorization: Bearer <token>
Content-Type: application/json
{ "item": { "@microsoft.graph.conflictBehavior": "replace", "name": "<name>.html" } }Response:
{ "uploadUrl": "https://…/…", "expirationDateTime": "2026-07-18T20:45:00Z" }Then PUT the bytes to uploadUrl in ordered ranges (recommended 320 KiB multiples), each
with a Content-Range header, e.g. Content-Range: bytes 0-26214399/128000000. The final
range returns the completed driveItem (with webUrl). Inlined HTML is almost always < 4 MB,
so the simple upload above is normally sufficient.
To rename or move without replacing content, PATCH the item (do not use …/content):
PATCH https://graph.microsoft.com/v1.0/me/drive/items/{item-id}
Authorization: Bearer <token>
Content-Type: application/json
{ "name": "renamed.html", "parentReference": { "id": "{new-parent-id}" } }Returns the updated driveItem.
Identical calls, SharePoint base URL, SharePoint-audience token:
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 …Site library drive:
PUT https://{tenant}.sharepoint.com/sites/{site}/_api/v2.0/drives/{driveId}/items/{itemId}/contentResponse is the same driveItem (read webUrl). Upload sessions
(/createUploadSession) and metadata PATCH work the same way against the Vroom base.
When you have the destination folder's URL (a OneDrive/SharePoint folder link), you can resolve it straight to a driveItem and upload into it — without knowing the drive/site/library id or enumerating libraries. This is the fewest-calls path and works uniformly for the user's OneDrive, any SharePoint site, and any (including non-default) document library.
1. Encode the folder URL as a sharing token (u! + unpadded base64url): base64-encode the URL,
strip trailing =, replace /→_ and +→-, then prepend u!. The URL must be a valid URI, so
percent-encode spaces (%20).
2. Resolve the folder (1 call) — read id and parentReference.driveId:
GET https://graph.microsoft.com/v1.0/shares/{u!token}/driveItem?$select=id,parentReference,webUrl
Authorization: Bearer {token}3. Upload into the folder (1 call):
PUT https://graph.microsoft.com/v1.0/drives/{driveId}/items/{folderId}:/<name>.html:/content?@microsoft.graph.conflictBehavior=replace
Authorization: Bearer {token}
Content-Type: text/html
<!DOCTYPE html> … your document …Returns the new file's driveItem; read webUrl and give the user <webUrl>?web=1.
Vroom equivalent: same paths under {host}/_api/v2.0 (e.g.
GET https://{tenant}.sharepoint.com/_api/v2.0/shares/{u!token}/driveItem) with a SharePoint-audience
token. Permissions: Files.ReadWrite (own drive) or Sites.ReadWrite.All (sites). See
shares_get for the full Shares API. A ready-made example is
scripts/publish-to-folder.sh.
To inspect a file while authoring (e.g. to learn a CSV's columns before building a dashboard), download it as the signed-in user:
GET /drives/{driveId}/items/{itemId}/content
This returns a 302 redirect to a short-lived pre-authenticated download URL — follow redirects
(fetch follows automatically; curl needs -L) or you get an empty body. (At view time the LiveData
flow does this for you server-side; this note is only for authoring-time inspection.)
SharePoint blocks some extensions from upload (e.g. .ashx, .asmx, .json, .soap,
.svc, .xamlx). Publish HTML as .html — it is allowed. (Serve JSON data via the
LiveData manifest, not as an uploaded .json.)
Errors return a JSON body:
{ "error": { "code": "invalidRequest", "message": "…", "innererror": { "code": "…" } } }| HTTP | code |
Cause / fix |
|---|---|---|
| 401 | unauthenticated |
Missing/expired token or wrong audience (Graph vs SharePoint). Re-acquire. |
| 403 | accessDenied |
Token lacks write scope, or the user can't write that location. |
| 404 | itemNotFound |
Wrong path/id, or parent folder missing (create it). |
| 409 | nameAlreadyExists |
Name conflict with conflictBehavior=fail. Use replace/rename. |
| 413 | resourceModified / too large |
> 4 MB on simple upload → use an upload session. |
| 423 | resourceLocked |
Item checked out / locked. |
| 507 | quotaLimitReached |
Drive is full. |
Always confirm with the user before an upload or overwrite — see the SKILL's Confirm before writing.