🔌 graftpunk
Authenticated browser sessions, captured once and replayed over plain HTTP: stealth login, encrypted at rest, pluggable storage.
Installation • Quick Start • Plugins • CLI Reference • Examples • Architecture
Log in through a real browser once; graftpunk captures the authenticated session — cookies, browser-fingerprinted headers, CSRF/API tokens — encrypts it at rest, and replays it over plain HTTP from Python or a generated CLI, so a site's own XHR/JSON endpoints become scriptable without a WebDriver in the loop.
The Problem
Plenty of services you have an account with expose no API — an ISP portal, a school or medical portal, a niche shop, a municipal records site. The data is yours and it is one login away, but every request has to look like it came from a browser that already signed in: the right cookies, the browser's own headers, whatever CSRF or bearer token the page minted. Reproducing that by hand for every script is the actual chore.
The Solution
graftpunk does the login in a real browser (yours, or a declaratively scripted one), captures the resulting session and header fingerprint, stores it encrypted, and hands it back as a requests-compatible session — locally, or from S3/Supabase when the same session needs to be shared.
1. LOG IN 2. CACHE 3. SCRIPT
+-------------+ +-------------+ +-------------+
| Browser | | Encrypted | | Python |
| Session |------>| Storage |------>| Script |
| | | | | |
+-------------+ +-------------+ +-------------+
Log in manually Session cached Use the session
or declaratively with AES-128 with real browser
via plugin config encryption headers replayed
Once your session is cached, you can:
- Make HTTP requests with your authenticated cookies and real browser headers
- Reverse-engineer XHR calls from browser dev tools
- Build CLI tools that feel like real APIs
- Automate downloads of documents and data
- Keep sessions alive with background daemons
- Capture network traffic for debugging and auditing
What You Can Build
Each of these is a plugin command backed by the cached session; graftpunk generates the CLI, injects the session and tokens, and formats the output:
# Pull your kid's grades and assignments
gp schoolportal grades --student emma --format table
# Download your medical lab results
gp mychart labs --after 2024-06-01 --output ./results/
# Export your energy usage data
gp utility usage --months 12 --format csv > energy.csv
# Scrape your property tax history
gp county assessor --parcel 12345 --format json
# Make ad-hoc requests with cached session cookies + browser headers
gp http get -s mychart https://mychart.example.com/api/appointments
These aren't real APIs—they're commands defined in graftpunk plugins that replay the same XHR calls the website makes. To the server, it looks like a browser. To you, it's just automation.
Installation
Logging in drives a real browser, so the default install includes the browser stack (Selenium, undetected-chromedriver, nodriver and their tooling):
pip install graftpunk
pip install 'graftpunk[browser]' still works and installs the same thing; the
extra is kept so existing requirements resolve.
Lite install, no browser:
pip install graftpunk-lite
graftpunk-lite is the same package (same import graftpunk, same version)
without the browser stack. It replays an already-cached session through
load_session_for_api_from_bytes, given the session's encrypted bytes and its
key. It cannot log in, because that needs a browser. Its dependencies are
pure-Python or ship WASM wheels, so this is the install that works under Pyodide
and Cloudflare Python Workers
(#121). The gp command is
installed. On graftpunk-lite, gp version, gp session list,
gp session show, gp config, gp plugins, gp http with --no-session, a
plugin command that needs no session, and load_session_for_api_from_bytes
work. Everything that loads a cached session from the session store
(load_session_for_api, gp http with a session from --session,
gp session use or GRAFTPUNK_SESSION, a plugin command that uses its session,
and gp session export) and every command that drives a browser (recording with
gp observe, and a plugin's login command) need graftpunk-lite[browser] or
graftpunk; on graftpunk-lite without it, those commands print the install
command and exit 1. pip install 'graftpunk-lite[browser]' adds the browser
stack and is equivalent to graftpunk.
Install one or the other, never both. They put the same files in the same
place, so uninstalling either one deletes files the other needs. To switch,
uninstall the one you have first (pip uninstall graftpunk, then
pip install graftpunk-lite, or the reverse).
Plugins declare graftpunk[browser], so install them where graftpunk is
installed. Installing one where graftpunk-lite is installed, with or without
[browser], pulls graftpunk in beside it, the both-installed state to avoid.
graftpunk-lite is for loading a saved session with
load_session_for_api_from_bytes under Pyodide or Cloudflare Python Workers, and
for the browser-free gp commands above.
With cloud storage:
pip install graftpunk[supabase] # Supabase backend
pip install graftpunk[s3] # AWS S3 backend
pip install graftpunk[all] # Everything
On the lite install, name graftpunk-lite instead (graftpunk-lite[s3]).
Quick Start
1. Cache a Session
The fastest way is with a plugin. Here's the httpbin example (no auth needed):
# Drop a YAML plugin into your plugins directory
mkdir -p ~/.config/graftpunk/plugins
cp examples/plugins/httpbin.yaml ~/.config/graftpunk/plugins/
# Use it immediately
gp httpbin ip
gp httpbin headers
gp httpbin status --code 418 # I'm a teapot!
For sites that require authentication, plugins can define declarative login:
# Log in via auto-generated command (opens browser, fills form, caches session)
gp quotes login
# Use the cached session for API calls
gp quotes list
gp quotes random
2. Use It Programmatically
from graftpunk import GraftpunkClient
# Use plugin commands from Python — same session, tokens, and retries as the CLI
with GraftpunkClient("mybank") as client:
accounts = client.accounts()
statements = client.statements(month="january", year=2024)
# Grouped commands use nested attribute access
detail = client.accounts.detail(id=42)
For lower-level access without plugins, load a session directly:
from graftpunk import load_session_for_api
# Returns a GraftpunkSession with browser headers pre-loaded
api = load_session_for_api("mysite")
response = api.get("https://app.example.com/api/internal/documents")
If you already hold the encrypted session blob and can't reach graftpunk's storage or key file — a Cloudflare Python Worker that read it through an R2 binding, say — go straight from bytes:
from graftpunk import load_session_for_api_from_bytes
api = load_session_for_api_from_bytes(encrypted_bytes, key=fernet_key)
response = api.get("https://app.example.com/api/internal/documents")
On graftpunk-lite, load_session_for_api_from_bytes works, and
load_session_for_api needs graftpunk-lite[browser] or graftpunk. key= is
optional and defaults to graftpunk's configured key sources; pass it when the
key lives somewhere graftpunk can't see, like a Worker secret. A bad key raises
EncryptionError (fix the key); an unusable session raises
SessionExpiredError (log in again).
3. Keep It Alive
Sessions expire. graftpunk can keep them alive in the background with the keepalive daemon.
Features
| Feature | Why It Matters | |
|---|---|---|
| 🥷 | Stealth Mode | Multiple backends: Selenium with undetected-chromedriver, or NoDriver for CDP-direct automation without WebDriver detection. Bot-detection cookies (Akamai, etc.) are automatically filtered during cookie injection to prevent WAF rejection. |
| 🔒 | Encrypted Storage | Sessions encrypted with AES-128 (Fernet). Local by default, optional cloud storage. |
| 🔑 | Declarative Login | Define login flows with CSS selectors. graftpunk opens the browser, fills the form, and caches the session. Works in both Python and YAML plugins. |
| 🌐 | Browser Header Replay | Captures real browser headers during login and replays them in API calls. Requests look like they came from Chrome, not Python. |
| 🔌 | Plugin System | Full command framework with CommandContext, resource limits, output formatting, and auto-generated CLI. Python for complex logic, YAML for simple calls. |
| 🛡️ | Token & CSRF Support | Declarative token extraction from cookies, headers, or page content. EAFP injection with automatic 403 retry. Tokens cached through session serialization. |
| 📡 | Observability | Capture screenshots, HAR files, console logs, and network traffic. Interactive mode lets you browse manually while recording. |
| 🔄 | Keepalive Daemon | Background daemon pings sites periodically to prevent session timeout. |
| 🛠️ | Ad-hoc HTTP | gp http get -s <session> <url> — make one-off authenticated requests without writing a plugin. |
| 📊 | Multi-View Output | Commands can define multiple views on response data. Table format renders each view as a separate section. XLSX creates one worksheet per view. --view lets you cherry-pick views and columns. |
| 🎨 | Beautiful CLI | Rich terminal output with spinners, tables, and color. --format json|table|csv|xlsx|raw on all commands. |
Plugins
A plugin turns one site into a CLI command group. It declares where the site is, how to log in, and what each command fetches; graftpunk supplies the browser login, the cached session, the browser headers, tokens, retries, and output formatting. Write one in Python for anything with logic in it, or in YAML for plain REST calls. Both support declarative login, resource limits, and output formatting.
Plugins are discovered from three sources: Python packages registered on the graftpunk.plugins entry-point group, YAML files in ~/.config/graftpunk/plugins/ (*.yaml, *.yml), and Python files in the same directory (*.py). Two plugins sharing a site_name is an error naming both sources, never a silent shadowing. Writing a graftpunk plugin is the guide: naming, recording a site, reading the recording, scaffolding, implementing, login, secrets, and tests.
In Claude Code, the /graftpunk:graft skill walks that guide with you and runs the gp commands itself; With the skill says how to install and run it.
YAML Plugin (Simple REST Calls)
For straightforward HTTP calls, no Python needed:
# ~/.config/graftpunk/plugins/mybank.yaml
site_name: mybank
base_url: "https://secure.mybank.example.com"
login:
url: /login
fields:
username: "input#email"
password: "input#password"
submit: "button[type=submit]"
commands:
accounts:
help: "List all accounts"
method: GET
url: "/api/accounts"
jmespath: "accounts[].{id: id, name: name, balance: balance}"
statements:
help: "Get statements for a month"
method: GET
url: "/api/statements"
params:
- name: month
required: true
help: "Month name"
- name: year
type: int
default: 2024
timeout: 30
max_retries: 2
Python Plugin (Complex Logic)
from graftpunk.plugins import CommandContext, LoginConfig, LoginStep, SitePlugin, command
class MyBankPlugin(SitePlugin):
site_name = "mybank"
base_url = "https://secure.mybank.example.com"
backend = "nodriver" # or "selenium"
api_version = 1
login_config = LoginConfig(
url="/login",
steps=[
LoginStep(
fields={"username": "input#email", "password": "input#password"},
submit="button[type=submit]",
),
],
success=".dashboard",
# The post-submit wait polls for these until it sees them, or gives up
# after timeout seconds (30 by default).
success_url="*/dashboard*",
)
@command(help="List all accounts")
def accounts(self, ctx: CommandContext):
return ctx.request_json("GET", "/api/accounts")
@command(help="Get statements for a month")
def statements(self, ctx: CommandContext, month: str, year: int = 2024):
return ctx.request_json("GET", f"/api/statements/{year}/{month}")
Using Plugins
# Login (auto-generated from declarative config)
gp mybank login
# Run commands
gp mybank accounts
gp mybank statements --month january --year 2024 --format table
# List all discovered plugins
gp plugins
See examples/ for working plugins and templates.
CLI Reference
$ gp --help
Usage: gp [OPTIONS] COMMAND [ARGS]...
🔌 graftpunk — Authenticated browser sessions, captured once and replayed over
plain HTTP: stealth login, encrypted at rest, pluggable storage.
Log in through a real browser once; graftpunk captures the authenticated
session — cookies, browser-fingerprinted headers, CSRF/API tokens — encrypts
it at rest, and replays it over plain HTTP from Python or a generated CLI, so
a site's own XHR/JSON endpoints become scriptable without a WebDriver in the
loop.
Commands:
version Show graftpunk version and installation info.
plugins List discovered plugins (storage, handlers, sites, CLI).
observe View and manage observability data (HAR, screenshots, logs).
session Manage encrypted browser sessions.
keepalive Manage the session keepalive daemon.
http Make ad-hoc HTTP requests with cached session cookies.
config Show configuration; manage the workstation env file.
plugin Scaffold, extend, inspect, upgrade, and check a graftpunk plugin project.
(Options and the Quick-start block are elided; the full text is gp --help.)
Session Management
gp session list # List all cached sessions
gp session show <name> # Session metadata (domain, cookies, expiry)
gp session clear <name> # Remove a session (or --all)
gp session export <name> # Export cookies to HTTPie session format
gp session use <name> # Set active session for subsequent commands
gp session unset # Clear active session
Sessions are keyed by plugin and account: logging into one site as two
accounts caches myshop@alice and myshop@bob side by side, and gp session list shows which account each one belongs to. Commands pick one with
--session myshop@alice (or GRAFTPUNK_SESSION / gp session use), and
refuse — listing the candidates — when several are cached and none is picked.
The label is derived from the login identifier; gp <site> login --as work
names it yourself.
Ad-hoc HTTP Requests
Make authenticated requests using cached sessions without writing a plugin:
gp http get -s mybank https://secure.mybank.example.com/api/accounts
gp http post -s mybank https://secure.mybank.example.com/api/transfer --data '{"amount": 100}'
Use --role to set browser header roles (built-in or plugin-defined):
gp http get -s mybank --role xhr https://secure.mybank.example.com/api/status
gp http get -s mybank --role api https://secure.mybank.example.com/v2/data # custom plugin role
Supports all HTTP methods: get, post, put, patch, delete, head, options.
Observability
Capture browser activity for debugging:
# Open authenticated browser and capture network traffic
gp observe -s mybank go https://secure.mybank.example.com/dashboard
# Interactive mode — browse manually, Ctrl+C to save
gp observe -s mybank interactive https://secure.mybank.example.com/dashboard
# Or use the --interactive flag on observe go
gp observe -s mybank go --interactive https://secure.mybank.example.com/dashboard
# View captured data
gp observe list
gp observe show mybank
gp observe clean mybank
Interactive mode opens an authenticated browser and records all network traffic (including response bodies) while you click around. Press Ctrl+C to stop — HAR files, screenshots, page source, and console logs are saved automatically.
Pass --observe full to any command to capture screenshots, HAR files, and console logs.
From recording to plugin
Record the site, read the recording, scaffold a project from it, then fill in
the stubs and test them against fixtures. A first capture has no cached session
to name, so it runs --no-session and is filed under the name graftpunk infers
from the host, which gp observe list prints (<name> below). Each step, with
the options and the rules, is in
Writing a graftpunk plugin:
Capture,
Understand, and
Scaffold.
gp observe --no-session interactive https://secure.mybank.example.com/dashboard
gp observe digest <name>
gp plugin new mybank --from-run <name>
--command "NAME=METHOD template", repeatable, scaffolds only the endpoints you
name, under your names (Choose the commands
yourself).
The generated tests read fixtures. Write the recorded bodies out as captures,
which land in the project's git-ignored tests/captures/, then copy each one
into tests/fixtures/ with its sidecar and replace every value with an
invented one
(Deriving a fixture from a capture):
gp observe fixtures <name> --match "GET /api/accounts"
Install the project with its dev extra, which brings pytest and ruff, then
run the project's gate before every commit
(The gate):
pip install -e ".[dev]"
pytest
ruff check .
ruff format --check .
gp plugin check
To keep the project current, gp plugin upgrade adds the wiring a newer
graftpunk expects, and gp plugin add-command adds one command from a later
recording (Add a command to an existing
plugin). When
the project's plain graftpunk>= bound is below the major.minor.0 of the
graftpunk you run them with, each one that writes something raises the bound to
that release and tells you to reinstall the project; The
gate covers every other form of the
requirement.
gp plugin upgrade
gp plugin add-command mybank --from-run <name> --command "statements=GET /api/statements"
Configuration
| Variable | Default | Description |
|---|---|---|
GRAFTPUNK_STORAGE_BACKEND |
local |
Storage: local, supabase, or s3 |
GRAFTPUNK_CONFIG_DIR |
~/.config/graftpunk |
Config and encryption key location |
GRAFTPUNK_SESSION_TTL_HOURS |
720 |
Session lifetime (30 days) |
GRAFTPUNK_LOG_LEVEL |
WARNING |
Logging verbosity |
GRAFTPUNK_LOG_FORMAT |
console |
Log format: console or json |
GRAFTPUNK_BROWSER_EXECUTABLE_PATH |
(system Chrome) | Path to a Chrome/Chromium binary for the nodriver backend (e.g. Chrome-for-Testing on machines/CI without a system Chrome install) |
GRAFTPUNK_KEEP_ORPHANED_CHROME |
(unset) | Set to 1 to keep the Chrome processes earlier runs left behind, instead of ending them before a nodriver browser starts |
CLI flags: -v (info), -vv (debug), --log-format json, --observe full, --network-debug (wire-level HTTP tracing).
Workstation configuration (gp config)
Persist per-machine environment for gp in ~/.config/graftpunk/env —
credentials as lazy 1Password (or any) command values, settings as statics:
gp config set GRAFTPUNK_BROWSER_EXECUTABLE_PATH "/path/to/chrome"
gp config set MYSHOP_USERNAME '$(op read "op://vault/item/username")'
gp config set MYSHOP_PASSWORD '$(op read "op://vault/item/password")'
gp config list
Static values load at startup; $(…) values run only when a command
actually needs them (login, first access of an allowlisted setting, or a
YAML plugin's ${VAR} header expansion) — gp --help never triggers your
secret manager. Real environment variables always win over the file — a
variable set to the empty string counts as unset, so an accidental
export FOO= doesn't shadow a workstation-file value. Single-quote command
values so your shell doesn't evaluate them at set time. See
docs/rfcs/2026-07-28-workstation-env.md for the full design.
Browser Backends
graftpunk supports two browser automation backends (both included by default):
| Backend | Best For |
|---|---|
selenium |
Simple sites, backward compatibility |
nodriver |
Enterprise sites, better anti-detection |
Why NoDriver? NoDriver uses Chrome DevTools Protocol (CDP) directly without the WebDriver binary, eliminating a common detection vector used by anti-bot systems.
Bot-detection cookie filtering: When injecting session cookies into a nodriver browser (for observe mode, token extraction, etc.), graftpunk automatically skips known WAF tracking cookies (Akamai bm_*, ak_bmsc, _abck). These cookies carry stale bot-classification state that causes WAFs to reject the browser with ERR_HTTP2_PROTOCOL_ERROR. Disable with skip_bot_cookies=False if needed.
from graftpunk import BrowserSession
# Use BrowserSession with explicit backend
session = BrowserSession(backend="nodriver", headless=False)
Custom Chrome binary: the nodriver backend auto-detects a system Chrome. Set GRAFTPUNK_BROWSER_EXECUTABLE_PATH to point it at a specific Chrome/Chromium binary (e.g. Chrome-for-Testing) on machines or CI without a system Chrome install.
Call timeouts: a synchronous nodriver call gives up on a Chrome that stops answering. navigate() waits up to 120 seconds and then raises BrowserError; the other synchronous calls (current_url, page_source, get_cookies(), and the rest) wait up to 60 seconds and then log a warning and return their empty fallback. A navigate() that times out also tells Chrome to stop loading the URL, so the tab stays on the page it showed before and the next call answers at once. Change the limits with BrowserSession(backend="nodriver", call_timeout=..., navigate_timeout=...) (or the same arguments to NoDriverBackend), in seconds; None waits without a limit.
Security
Your Data, Your Rules
graftpunk is for automating access to your own accounts. You're not scraping other people's data—you're building tools to access information that already belongs to you.
Some services may consider automation a ToS violation. Use your judgment.
Encryption
- Algorithm: Fernet (AES-128-CBC + HMAC-SHA256)
- Key storage:
~/.config/graftpunk/.session_keywith0600permissions - Integrity: SHA-256 checksum validated before deserializing
Best Practices
- Keep your encryption key secure
- Don't share session files
- Run graftpunk on trusted machines
- Use unique, strong passwords for automated accounts
Pickle warning: graftpunk uses Python's pickle for serialization. Only load sessions you created.
Development
git clone https://github.com/stavxyz/graftpunk.git
cd graftpunk
just setup # Install deps with uv
just check # Run lint, typecheck, tests
just build # Build for PyPI
Requires uv for development. See CONTRIBUTING.md for full guidelines.
Releasing
Releases publish to PyPI through GitHub Actions using PyPI Trusted Publishing (OIDC) — no API token is stored anywhere.
just bump X.Y.Z # opens a version-bump PR (pyproject, uv.lock, CHANGELOG)
# merge the PR, then on an up-to-date main:
just release # validates, tags vX.Y.Z, and pushes the tag
Pushing the tag triggers .github/workflows/release.yml, which runs the tests, builds an sdist and a wheel each for graftpunk and graftpunk-lite, installs each wheel into a clean venv and checks the lite contract (scripts/smoke_install.sh), publishes graftpunk-lite and then graftpunk to PyPI via OIDC, and creates the GitHub release. graftpunk-lite goes first so that a failed lite upload stops the run before graftpunk ships without it. Because build + publish run in CI on a pinned Python, releasing no longer depends on your local interpreter or any local credentials.
To (re)publish a tag that was cut before this workflow existed, or to retry a failed publish, run the workflow manually (Actions → Release → Run workflow) with the tag (e.g. v1.9.0). A tag cut before graftpunk-lite existed has no scripts/build_lite.py; the run then logs a notice, skips building, checking, smoke-testing and publishing graftpunk-lite, and publishes graftpunk alone.
One-time setup (repo owner, on pypi.org → graftpunk → Publishing): add a GitHub Actions trusted publisher with owner stavxyz, repository graftpunk, workflow release.yml, and environment pypi. graftpunk-lite needs the same publisher, added as a pending publisher for the project name graftpunk-lite (pypi.org → your account → Publishing) before the first release that builds it; that release creates the project.
License
MIT License. See LICENSE.
Acknowledgments
- requestium – Selenium + Requests integration
- undetected-chromedriver – Anti-detection ChromeDriver
- nodriver – CDP-direct browser automation
- cryptography – Encryption primitives
- rich – Beautiful terminal output
- typer – CLI framework
Metadata
Release files for graftpunk 1.21.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| graftpunk-1.21.0.tar.gz | 486.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| graftpunk-1.21.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 980.6 kB
Release files / graftpunk-1.21.0.tar.gz
| Download URL | graftpunk-1.21.0.tar.gz |
|---|---|
| Size | 486.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1ec0f003eb14d22fd7a706350b9791694de84647acf27775def38978422a642c
|
|
BLAKE2b-256 checksum How to use checksums |
d3fccee1f9b368ad4b98bd296c984eaaee37a6f40fde3483aa421d5e22b331c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / graftpunk-1.21.0-py3-none-any.whl
| Download URL | graftpunk-1.21.0-py3-none-any.whl |
|---|---|
| Size | 494.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
74710706cbcd5e6aefcb450e76469fe48cd0832d003cc80f2fa864ca0ec66b5d
|
|
BLAKE2b-256 checksum How to use checksums |
8de0642d8168bea96cd197741934b4219475c93186cbd70085752a1e051b09e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log