# Upload and run browser extensions
URL: /cookbook/extensions

---
title: Upload and run browser extensions
description: Use the Steel Extensions API with Playwright to upload and run browser extensions.
---

<RecipeJsonLd slug="extensions" title={"Upload and run browser extensions"} description={"Use the Steel Extensions API with Playwright to upload and run browser extensions."} authors={[{"handle":"aspectrr","name":"Collin Pfeifer"}, {"handle":"junhsss","name":"Jun Ryu"}]} datePublished="2024-11-19" dateModified="2026-04-24" sourceUrl="https://github.com/steel-dev/steel-cookbook/tree/92f29742253e2b6c6801d109e18232768e5291a0/examples/extensions" />

<RecipeMeta href="https://github.com/steel-dev/steel-cookbook/tree/92f29742253e2b6c6801d109e18232768e5291a0/examples/extensions" path="examples/extensions" authors={[{"handle":"aspectrr","name":"Collin Pfeifer","avatar":"https://github.com/aspectrr.png?size=40"}, {"handle":"junhsss","name":"Jun Ryu","avatar":"https://github.com/junhsss.png?size=40"}]} updated="2026-04-24" />

<RecipeQuickstart slug="extensions" />

Steel sessions launch a clean Chrome with nothing installed. The Extensions API lets you upload a Chrome extension once, get back an ID, and attach it to any future session via `extensionIds` on `sessions.create()`. Content scripts and background workers load before your first `page.goto`, so the extension has already rewritten the DOM by the time Playwright observes it.

```typescript
const extensionExists = (await client.extensions.list()).extensions.find(
  (ext) => ext.name === "Github_Isometric_Contribu",
);

const extension = extensionExists ?? await client.extensions.upload({
  url: "https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien",
});

session = await client.sessions.create({
  extensionIds: extension?.id ? [extension.id] : [],
});
```

Uploads persist on your account, so `extensions.list()` is the lookup that lets repeat runs skip the re-upload. Names come back normalized (truncated, underscored), which is why this one matches `Github_Isometric_Contribu` rather than the full store title.

The demo loads [GitHub Isometric Contributions](https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien), a Chrome extension that replaces GitHub's flat contribution square grid with a 3D isometric version and injects extra panels for streaks, best-day counts, and weekly totals. `scrapeStats` reads those extension-rendered numbers straight off the profile page.

## Run it

```bash
cd examples/extensions
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the extension render on a live GitHub profile.

Your output varies. Structure looks like this:

```text
Steel + Extensions API Starter
============================================================

Checking extension...
No existing extension found

Uploading extension...
Extension uploaded: { id: 'ext_...', name: 'Github_Isometric_Contribu', ... }

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Playwright
Navigating to junhsss's GitHub Profile

GitHub Stats for junhsss

 Stat             Value   Range / Date
 Contributions    1,284   in the last year
 This Week        37      this week
 Best Day         28      on Apr 3
 ...

Releasing session...
Session released
Done!
```

A run takes ~20 seconds and costs a few cents of session time. First run uploads the extension, later runs reuse the ID.

## How scrapeStats proves the extension loaded

`scrapeStats` in `stats.ts` targets markup the extension injects, not GitHub's stock profile. It waits on `div.ic-contributions-wrapper` (the `ic-` prefix is the extension's namespace), then walks nested `div.p-2` blocks to pull `span.f2` values for contributions, this-week totals, best-day counts, and streak ranges. If the extension never loads, none of those selectors resolve and the scrape hangs. That fragility is the demo: it fails loudly when the extension is missing, which is exactly how you confirm the session attached it.

`randomContributor` in `index.ts` fetches the [steel-browser](https://github.com/steel-dev/steel-browser) contributor list from the GitHub API and picks one. The main loop retries three times across different usernames if a profile fails to render, mostly as a hedge against transient rate limits on avatars.

## Make it yours

- **Upload your own extension.** `client.extensions.upload({ url })` accepts any Chrome Web Store listing URL. Swap the URL, and change the name that `extensions.list()` checks for (remember the truncated, underscored form).
- **Target a specific username.** Replace the `randomContributor` call in `index.ts` with a hardcoded string. The scraper works against any public profile.
- **Stack extensions.** `extensionIds` is an array. Upload multiple (ad blocker, cookie consent killer, a helper content script) and attach them together.
- **Combine with stealth.** Uncomment `useProxy` or `solveCaptcha` in the `sessions.create()` call if the sites your extension targets fight bots.

## Related

[Credentials](/cookbook/credentials) (persist cookies across runs) · [auth-context](/cookbook/auth-context) (seed logged-in state) · [profiles](/cookbook/profiles) (reuse a full browser profile) · [Playwright docs](https://playwright.dev)

## Related recipes

<RecipeGrid>
<RecipeCard slug="credentials" title={"Automate logins with the Credentials API"} description={"Use the Steel Credentials API with Playwright to automate flows with stored credentials."} topics={['Steel APIs', 'Authentication', 'Playwright']} date="2024-11-19" />
<RecipeCard slug="files" title={"Move files between your machine and a cloud browser"} description={"Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud."} topics={['Steel APIs', 'Playwright']} date="2024-11-19" />
<RecipeCard slug="convex-price-watch" title={"Watch Claude pricing for divergent A/B variants"} description={"Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree."} topics={['Steel APIs', 'Convex']} date="2026-05-04" />
</RecipeGrid>
