Upload and run browser extensions

Use the Steel Extensions API with Playwright to upload and run browser extensions.

examples/extensions-ts
Contributors: , Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

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.

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, 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

cd examples/extensions-ts
cp .env.example .env # set STEEL_API_KEY
npm install
npm start

Get a key at 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:

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

Credentials (persist cookies across runs) · auth-context (seed logged-in state) · profiles (reuse a full browser profile) · Playwright docs

examples/extensions-py
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A fresh Steel session boots a clean Chrome with no extensions installed. The Extensions API closes that gap: you upload a Chrome extension once, Steel stores it under your account and hands back an ID, and you pass that ID to sessions.create(extension_ids=[...]). Content scripts run before your first page.goto, so by the time Playwright attaches the extension has already mutated the DOM.

This port keeps the recipe to its core primitive. It uploads (or reuses) the extension, attaches it, opens a GitHub profile, and waits for the one DOM node the extension injects. It does not scrape and pretty-print the rendered stats. The presence of that node is the whole proof.

existing = next(
(ext for ext in client.extensions.list().extensions if ext.name == "Github_Isometric_Contribu"),
None,
)
extension = existing or client.extensions.upload(url=EXTENSION_URL)
session = client.sessions.create(extension_ids=[extension.id])

Uploads persist, so extensions.list() is the lookup that lets a second run skip the re-upload. Names come back normalized (truncated and underscored), which is why the match is against Github_Isometric_Contribu and not the full store title.

What "confirmed" means here

The demo loads GitHub Isometric Contributions, an extension that rebuilds GitHub's flat contribution grid as a 3D isometric chart inside a div.ic-contributions-wrapper (the ic- prefix is the extension's own namespace). Stock GitHub never renders that node. So the test is simple: navigate to a profile and page.wait_for_selector("div.ic-contributions-wrapper"). If the selector resolves, the session attached the extension and it ran. If it times out, the script says so and moves on to release. No scraping, no table, just a yes or no on whether the injected UI showed up.

Run it

cd examples/extensions-py
cp .env.example .env # set STEEL_API_KEY
uv run main.py

Grab a key at app.steel.dev/settings/api-keys. uv sync runs automatically on first uv run, so there is no separate install step. 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:

Steel + Extensions (Python)
============================================================
Checking for an existing extension...
No existing extension found
Uploading extension...
Uploaded extension: ext_...
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...
Connected to browser via Playwright
Navigating to https://github.com/junhsss ...
Waiting for injected element: div.ic-contributions-wrapper
Injected element appeared: the extension loaded into the page.
Releasing session...
Session released
Done!

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

Make it yours

  • Upload your own extension. client.extensions.upload(url=...) accepts any Chrome Web Store listing URL. Swap EXTENSION_URL, then update EXTENSION_NAME to the truncated, underscored form extensions.list() returns.
  • Confirm a different node. Change INJECTED_SELECTOR to whatever your extension adds to the page. The wait is the proof, so pick a selector that only exists when the extension ran.
  • Target a specific profile. Set PROFILE_URL to any public GitHub user, such as https://github.com/steel-dev.
  • Stack extensions. extension_ids is a list. Upload several (ad blocker, consent killer, a helper content script) and attach them in one session.

extensions-ts (same recipe, plus a styled stats table) · extensions-go · extensions-rs · profiles-py (reuse a full browser profile) · Playwright docs

examples/extensions-rs
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel session boots a clean Chromium with no extensions installed. The Extensions API closes that gap: upload a Chrome extension once with client.extensions().upload(...), get back an ext_... id, and attach it to any later session by setting extension_ids on SessionCreateParams. Steel loads the content scripts and background workers before the first navigation, so by the time chromiumoxide opens the page the extension has already run.

This recipe uploads GitHub Isometric Contributions, which replaces GitHub's flat contribution grid with a 3D isometric one wrapped in div.ic-contributions-wrapper. That wrapper is the proof: it does not exist on a stock GitHub profile, so finding it on the page means the session attached and ran the extension.

Upload once, reuse forever

Uploads persist on your account, so re-running should not re-upload. resolve_extension lists what is already there and matches on the name Steel hands back, which is truncated and underscored (Github_Isometric_Contribu, not the full store title). A hit reuses the id; a miss uploads from the store URL and uses the fresh id. Either path produces one id, and that single value is all SessionCreateParams needs:

let session = client
.sessions()
.create(SessionCreateParams {
extension_ids: Some(vec![extension_id]),
..Default::default()
})
.await?;

Confirming the injection

chromiumoxide has no wait_for_selector, so wait_for_selector here polls the page itself: it runs !!document.querySelector('div.ic-contributions-wrapper') through page.evaluate(...).into_value() once a second for up to 15 tries and stops on the first true. The program prints whether the wrapper showed up rather than scraping the numbers inside it; the goal is to confirm the DOM was rewritten, not to read it. If the extension never attached, the selector stays absent for all 15 attempts and the run says so.

Run it

cd examples/extensions-rs
cp .env.example .env # set STEEL_API_KEY
cargo run

Grab a key at app.steel.dev/settings/api-keys. The first build pulls chromiumoxide and tokio and takes a minute or two. As the program starts it prints a session viewer URL; open it in a second tab to watch the isometric grid render live.

Your output varies. Structure looks like this:

Checking for extension Github_Isometric_Contribu...
Not found, uploading from the Chrome Web Store...
Uploaded Github_Isometric_Contribu (ext_ab12cd34)
Using extension ext_ab12cd34
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34
Connected over CDP, opening https://github.com/junhsss...
Extension injected div.ic-contributions-wrapper; the contribution grid was rewritten.
Releasing session...
Session released

The first run uploads the extension; later runs print Reusing uploaded extension and skip straight to the session. main captures the run result, releases the session, then returns the error, so a failed check still tears the session down instead of leaving it to idle out.

Make it yours

  • Upload your own extension. upload(...) takes either a url (any Chrome Web Store listing) or a file (a .zip/.crx you supply). Swap EXTENSION_URL and update EXTENSION_NAME to the truncated, underscored name extensions().list() reports back.
  • Target a specific profile. PROFILE_URL is just a constant; point it at any public GitHub profile.
  • Stack extensions. extension_ids is a Vec. Upload several (an ad blocker, a consent killer, a helper content script) and pass all their ids together.
  • Assert instead of print. Turn the wait_for_selector boolean into a hard failure if you want the run to exit non-zero when the extension does not load.
  • extensions-ts is the original this ports, driving Playwright and scraping the injected stats into a table.
  • extensions-py and extensions-go are the same upload-and-attach flow in Python and Go.
  • profiles-rs persists a full browser profile across sessions, the heavier sibling to attaching extensions per run.
  • chromiumoxide docs cover Page, evaluate, and find_element in full.
examples/extensions-go
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A fresh Steel session boots a stock Chrome with no extensions. The Extensions API lets you upload a Chrome extension once, keep the returned ID on your account, and attach it to any session through ExtensionIDs on Sessions.Create. Content scripts run before chromedp ever issues a Navigate, so by the time the page renders the extension has already rewritten the DOM.

This recipe proves that attachment happened by waiting on a selector the extension creates, nothing more. It does not scrape or pretty-print the numbers the extension renders.

list, _ := client.Extensions.List(ctx)
for _, ext := range list.Extensions {
if ext.Name == "Github_Isometric_Contribu" {
extID = ext.ID
}
}
if extID == "" {
uploaded, _ := client.Extensions.Upload(ctx, steel.ExtensionUploadParams{
URL: steel.Ptr("https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien"),
})
extID = uploaded.ID
}
sess, _ := client.Sessions.Create(ctx, steel.SessionCreateParams{
ExtensionIDs: steel.F([]string{extID}),
})

How the confirmation works

The demo attaches GitHub Isometric Contributions, which swaps GitHub's flat contribution grid for a 3D isometric one under a wrapper element it namespaces with ic-. After navigating to a profile, chromedp.WaitVisible("div.ic-contributions-wrapper", chromedp.ByQuery) runs against a 30-second timeout context. If the element appears, the extension loaded; if the context expires first, the wait returns an error and the run reports that the UI never showed. That selector belongs to the extension alone, so its presence is the proof.

Uploads persist on your account, which is why Extensions.List is the first call: a repeat run finds the existing ID and skips the re-upload. Names come back normalized, truncated and underscored, so the match is against Github_Isometric_Contribu rather than the full store title.

Run it

cd examples/extensions-go
cp .env.example .env # set STEEL_API_KEY
go mod tidy
go run .

Get a key at app.steel.dev/settings/api-keys. The session viewer URL prints as the run starts; open it in another tab to watch the extension render on a live profile.

Looking for an existing extension upload...
Not found. Uploading from the Chrome Web Store...
Uploaded extension ext_abc123
Creating Steel session with the extension attached...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34
Navigating to https://github.com/junhsss...
Waiting for the extension to inject "div.ic-contributions-wrapper"...
Extension UI confirmed: the session attached and rewrote the DOM.
Releasing session...

Make it yours

  • Upload your own extension. Extensions.Upload takes any Chrome Web Store listing URL. Swap storeURL and update the extensionName that Extensions.List matches on, remembering the truncated, underscored form.
  • Target a different profile. Change profileURL to any public GitHub user.
  • Stack extensions. ExtensionIDs is a slice. Upload several and attach them together in one Sessions.Create.
  • Assert on real content. Once the wrapper is visible, add chromedp.Text or chromedp.Evaluate steps to pull values the extension injected.

extensions-ts (Playwright sibling) · extensions-py · extensions-rs · profiles-go (reuse a full browser profile) · chromedp docs