Share an unpublished extension build for review
You have a build in dist/ that is not in a store and should not be. Someone needs to look at it: a designer, a product manager, a client, the person who filed the bug.
Mint a share and hand out the answer:
https://preview.extension.dev/?preview=gen_ndj4t8x2Whoever opens that link watches your extension run in a browser inside their tab. They install nothing, sign in to nothing, and change nothing about their own browser. The rest of this page is how to mint the link, what it hands over, how long it lives, and how to kill it.
Three ways to mint it
From an agent. With the MCP server configured, one tool call builds, uploads, and returns the link:
{
"tool": "extension_preview_web",
"projectPath": ".",
"share": true
}Without share: true the tool uploads nothing and returns a deep link that only opens on your machine. Without a token, sharing fails with a login hint and the local preview still succeeds, so a missing token never costs you the build.
From the console. Open the build in the console and share it from there.
From the API. The upload the agent performs is a POST to /api/artifacts with a project token (see Access tokens), carrying the dist/ files inline. It answers with the previewUrl, the zipUrl, the revokeUrl and the expiresAt.
Not the same as /api/cli/publish
POST /api/cli/publish is a different verb. It uploads nothing. It takes a buildSha your project has already built and returns a page on your workspace host, https://<workspace>.extension.dev/<project>/builds/<sha>, with a short-lived ?share= token appended when the project is private. Its ttlHours (1 to 168, default 24) is the lifetime of that token, not of a preview link. See Public build pages.
What the reviewer sees
They land on preview.extension.dev, which exists to be this door and nothing else. The page fetches the build, opens a session in the sandbox, and renders the running extension together with a summary of what it is: the name and version, how long the link has left, and every permission and host the manifest declares.
Be clear about the ordering, because it is the opposite of what a security notice usually implies. That summary is drawn from the manifest of a build that is already loaded and already running. It is a description, not a consent step. There is no prompt, nothing to accept, and nothing to decline, and a reviewer who reads the permission list and dislikes it is reading about code that has already started.
That is deliberate, and the reason it is safe to do is that the disclosure was never what protected them. The sandbox is. The build's real code executes on a separate origin, in a frame the page cannot reach into and that cannot reach the reviewer's browser profile, cookies, or installed extensions. Reading the permission list earlier would not change what the code can do.
Reversing that would mean promising a boundary the ordering does not supply, so the boundary itself is enforced instead: a shared build now runs isolated or it does not run at all. When the isolated origin is unavailable, the page refuses, says the sandbox is unavailable and that nothing was loaded, and stops. It used to quietly fall back to serving the build in the page's own renderer and explain afterwards, which was an apology for a decision the reviewer had never been offered.
That refusal is not for shared links only. A build you load from your own disk asks for the same isolated origin and gets the same refusal, so nothing runs inside this page there either. The one lane that still falls back is a build your own dev server hands over through a ?url= link, and there the page tells you it did, because those bytes never left your machine.
Once it is running, the surfaces the manifest declares, the popup, options page, side panel, background worker and content scripts, render in the frame, and a Trace tab lists every chrome.* call the extension makes while they use it, which is usually the fastest way for a reviewer to describe a bug precisely.
The trade
A share link hands over the built code, not just a rendering of it. zipUrl serves the whole uploaded dist/ as a .zip, unauthenticated, to anyone holding the link, for as long as the link is live.
That is normally what you want for a review, and it is exactly what a zip over chat would have done. But do not treat the link as a viewing-only wall, and do not share a build whose bundled code you would not hand out.
The uploader drops .env files and .pem, .key, .p12, .pfx and .keystore files anywhere under dist/ before anything leaves the machine. That is a narrow net for the common accident, not a secret scanner. A key pasted into a bundled .js file ships.
How long it lives
A shared preview lives 30 days on Free and 365 days on Pro. The window is a plan benefit, and it is the one place where Pro buys time instead of volume. Your plan is read when the link is minted, so the link keeps the window it was born with.
After it expires the link stops resolving, and the sweep deletes both the stored build and its record.
Re-sharing does not mint a second link. Uploading the same bytes from the same project while the link is still live returns that same link, and doing it after the link has expired reuses the same address with a fresh window, which is the closest thing to an extension there is. Only a revoked address is replaced, and it is replaced by a different one.
How to kill it
DELETE the revokeUrl with the same token, or ask the agent: extension_shares with action: "revoke". Any URL of the share pasted in is enough to name it.
WARNING
Revocation is permanent, and it stays permanent.
Revoking deletes the stored build immediately and marks the record revoked. The record itself is deliberately kept, as a tombstone, and the sweep leaves it alone forever instead of tidying it away. That matters more than it sounds: a share id is derived from the build's own bytes, so if the tombstone were ever collected, re-uploading the same build would land back on the exact address that was revoked and silently restore access for everyone still holding the old link. The surviving record is what stops that. Each revocation is mixed into the derivation, so the next share of the same bytes gets a different address, and repeated revocations keep moving it.
You get a new link. Everyone holding the old one gets nothing.
Manage shares covers finding, listing, and revoking in full.
Two more doors, both for a build on your own machine
No link to hand out. Open preview.extension.dev and load the build yourself. There are two import controls, and they sit on two different surfaces:
- On the page. Open the menu beside Preview build and choose Upload your
dist/folder. This is a folder picker, so it takes the directory that holdsmanifest.jsonand never an archive. - Inside the emulated browser. Its new tab page is an import surface of its own, with a Choose folder button, a Choose .zip button, and a field of its own for a preview link. It is the only surface here that takes a
.zip.
Either way the build runs in the same emulator and nobody else can reach it, because no share link is minted. The bytes are another matter, and the menu item says where they go before you choose anything: on the hosted site they are uploaded to a single-use sandbox we operate, which runs the build outside the page and stops serving it 30 minutes later. Only a build your own dev server hands back stays on your machine. Good for looking before deciding to share.
A real browser. Append ?lane=real, or switch Preview lane to Real on the Settings tab, and the preview forwards chrome.* calls to a Live Preview carrier extension in an actually installed browser. Where Chrome refuses a call, the lane says so instead of faking an answer.
The real lane is refused on a share link
Adding ?lane=real to a ?preview= address changes nothing, and the page says why: the build arrived over a share link, so it stays in the emulator. The real lane is only offered for a build loaded from the machine you are sitting at. It is settled the moment the address is read, so waiting for the build to finish loading does not change the answer.
Send the reviewer the share link, and keep the real lane for your own checkout.
What a share can hold
| Limit | Value |
|---|---|
Files in dist/ | 2,000 |
| Payload, text-heavy build | about 64MB |
| Payload, binary-heavy build | about 48MB |
| Link lifetime | 30 days on Free, 365 days on Pro |
The two payload numbers are one ceiling, not two. The cap counts encoded characters, and files that are not valid UTF-8 text travel base64-encoded, which costs four characters for every three bytes. So a build of images, fonts and wasm spends its budget a third faster than a build of JavaScript and JSON, and runs out at roughly 48MB of actual bytes.
A refusal reports what it measured in the units you can act on, so it names your build's encoded size and both ceilings rather than quoting the raw cap at a build that never approached it.
When it fails
- Sharing fails with a login hint: the token is missing or lapsed. The local preview still succeeded; mint a token and share again.
- The link answers that it expired or was revoked: that is the whole message by design. The viewer never sees your extension's name. Re-share for a fresh link.
- The upload is refused for size: read the number in the message, not the ceiling. Source maps are the usual cause on a text-heavy build, so build without them; bundled media is the usual cause on a binary-heavy one.
- The reviewer is told the sandbox is unavailable: nothing loaded, and nothing about your build is wrong. The isolated origin was unreachable and a shared build will not run without it. Ask them to retry.
Next
- Manage shares for the record, the list, and the revoke
- Public build pages for a build anyone should find, rather than one person