Overview & Quickstart
What is aimlib?
aimlib is a mobile-proxy and managed-browser service. When you rent a device through aimlib, you get:
- Raw mobile proxy: A proxy running on the device's real mobile IP, usable immediately with any HTTP client.
- Optional managed stealth browser: An on-demand, containerized Chrome browser that egresses through the same device mobile IP, providing a stealth browsing environment with a resolved device fingerprint.
Both the proxy and the browser share the same device identity — same mobile IP, same carrier, same region, same resolved fingerprint.
Authentication
aimlib uses a single API key:
- Format:
ak_live_...(provided by aimlib) - Transmission: HTTP header
Authorization: Bearer <your-api-key> - Base URL: The regional gateway tied to your lease, e.g.
https://uswest1.aimlib.com. This gateway serves both the/v1REST surface and the browser CDP relay. - Override the base URL if your lease is in a different region.
The SDK sets the auth header automatically; you only supply the API key and (optionally) the base URL.
Installation
The SDK targets Python 3.10+. The proxy surface needs only httpx; the managed browser additionally needs Playwright.
# Proxy only:
pip install "aimlib @ https://docs.aimlib.com/sdk/aimlib-0.2.0-py3-none-any.whl"
# Proxy + managed browser:
pip install "aimlib[browser] @ https://docs.aimlib.com/sdk/aimlib-0.2.0-py3-none-any.whl"
playwright install chromium # only needed to drive the browser
# Configure
export AIMLIB_API_KEY=ak_live_...
export AIMLIB_BASE_URL=https://uswest1.aimlib.com
Quick Start: Raw Proxy + Managed Browser
import asyncio
from aimlib import Aimlib
async def main():
# Reads AIMLIB_API_KEY (required) and AIMLIB_BASE_URL (optional) from env.
ai = Aimlib()
# Your leased devices
devices = await ai.devices.list()
device = devices[0]
# === Raw mobile proxy (ready immediately, any HTTP client) ===
print(f"Mobile proxy: {device.proxy.url}")
# import requests
# resp = requests.get("https://example.com",
# proxies={"http": device.proxy.url, "https": device.proxy.url})
# === Managed stealth browser ===
# device.browser(...) is a coroutine: await it to start (provision) a session.
# Using it as an async context manager connects on entry and stops on exit.
async with await device.browser(profile="pixel-8-pro", ttl="10m") as session:
# Entering the context waits for the container to boot (~55-60s on first
# connect), then connects over the CDP relay. `session` is a BrowserSession.
page = await session.new_page()
await page.goto("https://example.com")
print(await page.title())
# Additional tabs share the same session (same IP, same fingerprint),
# up to session.max_tabs (default 5).
page2 = await session.new_page()
# Context exit calls session.stop() (closes the browser + DELETEs the session).
await ai.aclose()
asyncio.run(main())
Device & Proxy Objects
device = await ai.device("device-id") # fetch a specific leased device
device.id # Device ID
device.region # Region (may be None)
device.carrier # Mobile carrier (may be None)
device.current_egress_ip # Current egress IP (may be None)
device.proxy # Proxy object, or None if no proxy is attached
device.proxy.url # Raw proxy URL
device.proxy.protocol # Proxy protocol
device.proxy.status # Proxy status
device.proxy.id # Proxy ID
ai.device(id)resolves by listing your leased devices and matching the ID; it raisesAimlibErrorif the device is not leased to your account.
Browser Sessions
device.browser(...) is an async method that provisions a session and returns a BrowserSession. All keyword arguments are optional; any you omit are left to the server's defaults.
async with await device.browser(
profile="pixel-8-pro", # device name (str), a Profile object, or a dict
ttl="10m", # session time-to-live: int seconds or "10m"/"1h"/"30s"
idle_timeout="5m", # auto-stop after this idle period
sticky=True, # request a sticky session
) as session:
page = await session.new_page()
await page.goto("https://example.com")
# Or manage the session manually:
session = await device.browser(profile="random", ttl="1h")
await session.connect() # waits until ready, returns the Playwright Browser
page = await session.new_page()
# ...
await session.stop() # closes the browser and DELETEs the session
Notes:
- ttl and idle_timeout accept an integer number of seconds, or a string with a unit suffix s/m/h (e.g. "30s", "10m", "1h"). A bare number string is treated as seconds.
- await session.connect() returns the live Playwright Browser. Entering the async context manager (async with) returns the BrowserSession itself — call session.new_page() on it.
profile parameter:
- profile="pixel-8-pro" (string): an aimlib standardized device name, e.g. "pixel-8-pro", "galaxy-s24-ultra", or "random".
- profile=Profile(...) (object): override individual fields (see Device Profiles).
- profile={...} (dict): a raw override dict, passed through as-is.
Core Concepts
One Session = One Identity
All tabs opened in a single browser session share: - The same mobile IP — egressing through the device's real mobile carrier. - The same resolved fingerprint — user agent, screen size, timezone, locale, etc.
The per-container tab count is bounded by session.max_tabs (server-configured, default 5). Calling new_page() past the limit raises TabLimitError. For parallel work under different identities, open separate sessions.
On-Demand Container Boot
The first connect (entering the async context manager, or calling connect()) waits for the container to provision and Chrome to come up — typically 55-60 seconds. The SDK polls readiness with a default timeout of 180 seconds (3 minutes); exceeding it raises SessionTimeout. Subsequent new_page() calls are fast.
Browser Runs on aimlib Infrastructure
The managed browser runs in aimlib's container infrastructure, but all outbound traffic egresses through your device's real mobile IP: - Websites see the device's real mobile IP and a mobile fingerprint. - The fingerprint (screen, timezone, OS, etc.) matches the requested device profile.
No Billing in Beta
During beta there is no usage billing. Test freely.
Device Profiles
Customize device characteristics with the Profile object. Omit any field to keep the profile's default.
from aimlib import Profile
profile = Profile(
device="pixel-8-pro", # standardized device name
timezone="America/Los_Angeles",
locale="en-US",
cores=4,
ram_gb=8,
screen=(1440, 3120), # (width, height)
dpi=512,
)
async with await device.browser(profile=profile) as session:
page = await session.new_page()
Profile fields: device, timezone, locale, cores, ram_gb, screen, dpi.
Error Handling
The SDK raises typed exceptions (all subclass AimlibError):
from aimlib import (
AimlibError,
CapacityError,
LeaseInactiveError,
DataCapError,
SessionExpiredError,
SessionTimeout,
TabLimitError,
)
try:
async with await device.browser(ttl="10m") as session:
page = await session.new_page()
except CapacityError:
print("No container capacity available; retry later")
except SessionTimeout:
print("Session was not ready within the timeout (~3 minutes)")
except SessionExpiredError:
print("Session not found / expired")
except TabLimitError:
print("Hit the per-session tab limit; open a separate session")
except LeaseInactiveError:
print("Device lease is inactive")
except DataCapError:
print("Data cap exceeded")
except AimlibError as e:
print(f"API error: {e}")
| Exception | Raised when (server error code) |
|---|---|
CapacityError |
capacity_unavailable |
LeaseInactiveError |
lease_inactive |
DataCapError |
data_cap_exceeded |
SessionExpiredError |
session_expired (also raised locally if the session 404s while waiting) |
SessionTimeout |
session_provisioning, or the readiness wait exceeds its timeout |
TabLimitError |
opening more tabs than session.max_tabs |
AimlibError |
base class / any other API error |
Environment Variables
| Variable | Default | Description |
|---|---|---|
AIMLIB_API_KEY |
(required) | Your API key (ak_live_...) |
AIMLIB_BASE_URL |
https://uswest1.aimlib.com |
Regional gateway URL; override for other regions |
REST API
The customer-facing REST API is served at the regional gateway (e.g., https://uswest1.aimlib.com) and uses bearer token authentication with customer API keys.
Authentication
All /v1 endpoints require the Authorization header carrying the customer API key:
Authorization: Bearer ak_live_...
- A missing or malformed token, or one that is unknown/revoked/expired or not a customer key, returns
401 Unauthorizedwith{"error": "unauthorized"}. - A token whose customer account is not
activereturns403 Forbiddenwith{"error": "lease_inactive", "message": "customer not active"}. - Per-endpoint scopes control access. A missing scope returns
403 Forbiddenwith{"error": "forbidden", "message": "missing scope <scope>"}.
Endpoints
GET /v1/devices
List the devices the caller holds an active lease on, each with its raw proxy URL and a browser-availability flag.
Authentication: Authorization: Bearer <api_key> (scope: proxies.read)
Request: (none)
Response: 200 OK — array of device objects (empty array if the caller has no active leases)
[
{
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"region": "uswest1",
"carrier": "t-mobile",
"lease": {
"id": "660e8400-e29b-41d4-a716-446655440000",
"ends_at": "2026-12-31T23:59:59Z"
},
"current_egress_ip": "203.0.113.45",
"proxy": {
"id": "770e8400-e29b-41d4-a716-446655440000",
"protocol": "socks5",
"url": "socks5h://user:pass@uswest1.aimlib.com:12345",
"status": "active"
},
"browser": {
"available": true
}
}
]
| Field | Type | Notes |
|---|---|---|
device_id |
UUID (string) | The physical device |
region |
string | Gateway region (e.g., "uswest1") |
carrier |
string | Active carrier (e.g., "t-mobile") |
lease.id |
UUID (string) | Active lease ID |
lease.ends_at |
ISO 8601 timestamp or null |
Lease expiration; null if open-ended |
current_egress_ip |
string | Device's current public IP. Present only when the lease has a proxy |
proxy |
object | Present only when the lease has a fully provisioned proxy (auth + gateway) |
proxy.id |
UUID (string) | Proxy ID |
proxy.protocol |
string | "http" or "socks5" |
proxy.url |
string | Full proxy URL with credentials. Scheme is http:// for HTTP proxies and socks5h:// for SOCKS proxies (host-side DNS resolution) |
proxy.status |
string | Proxy status (e.g., "active") |
browser.available |
boolean | Always true (every leased device offers an on-demand browser) |
Error Responses:
500 Internal Server Error:{"error": "server error"}— failure listing the caller's leases.
POST /v1/devices/{id}/browser
Create (or adopt a pre-bound) browser session on a device. The browser egresses through the device's mobile proxy IP. The response returns immediately with status: "provisioning"; the container takes roughly 55-60s to boot.
Authentication: Authorization: Bearer <api_key> (scope: sessions.write)
Path Parameters:
- id (UUID): device ID from /v1/devices
Request Body: (all fields optional; an empty body is valid)
{
"ttl": 300,
"idle_timeout": 120,
"sticky": false,
"profile": {
"android_version": "14",
"chrome_version": "126",
"device": "Pixel 8"
}
}
| Field | Type | Default | Notes |
|---|---|---|---|
ttl |
int (seconds) | 1800 (30 min) | Max session lifetime. Values <= 0 or above the 30-minute ceiling are set to the ceiling; then clamped down to the remaining lease duration |
idle_timeout |
int (seconds) | 120 | Release the session after this many seconds of inactivity. Values <= 0 use the default |
sticky |
boolean | (unused) | Accepted but not currently acted on |
profile |
JSON object | {} |
Transparent spoof overrides; max 4 KiB; must be a JSON object; unknown keys are stripped |
Allowed profile keys (any other key is silently dropped): device, timezone, locale, cores, ram_gb, screen, dpi, model, brand, manufacturer, android_version, build_fingerprint, security_patch, webgl, touch_points, chrome_version, tls_randomize, webrtc_block, ipv6_block, dns_leak_block, fonts, battery, audio_48khz, storage_quota
Response: 201 Created
{
"session_id": "880e8400-e29b-41d4-a716-446655440000",
"status": "provisioning",
"connect_url": "wss://uswest1.aimlib.com/cdp/880e8400-e29b-41d4-a716-446655440000",
"connect_token": "abcdef1234567890abcdef1234567890",
"region": "uswest1",
"ready_in_s": 60,
"egress_ip": null,
"expires_at": "2026-06-07T15:30:00Z",
"max_tabs": 5
}
| Field | Type | Notes |
|---|---|---|
session_id |
UUID (string) | Browser session ID |
status |
string | Always "provisioning" on creation (becomes "ready" once booted) |
connect_url |
string | WebSocket (wss://) CDP-relay URL at the device's regional gateway, of the form <gateway>/cdp/<session_id> |
connect_token |
string | Bearer token for the /cdp/{session_id} WebSocket connection (24-byte random token; returned only here) |
region |
string | Gateway region where the session is booted |
ready_in_s |
int | Estimated cold-to-driveable boot time in seconds (currently 60; measured ~55s on the baked image) |
egress_ip |
null |
Always null on creation; populated once the session reaches ready (query via GET) |
expires_at |
ISO 8601 timestamp | Hard deadline; the requested TTL clamped to the 30-minute ceiling and the lease end |
max_tabs |
int | Max concurrent tabs per session, enforced by the SDK (default 5; server-configurable) |
Error Responses:
| Status | Body | Notes |
|---|---|---|
400 Bad Request |
{"error": "bad id"} |
Path id is not a valid UUID |
400 Bad Request |
{"error": "profile_invalid", "message": "..."} |
Profile is not a JSON object or exceeds 4 KiB |
403 Forbidden |
{"error": "lease_inactive", "message": "no active lease with a proxy on this device"} |
Caller has no active lease on this device, or the lease has no proxy |
404 Not Found |
{"error": "device not found"} |
Device ID not found |
409 Conflict |
{"error": "data_cap_exceeded", "message": "device monthly cellular cap reached"} |
Device has exhausted its monthly cellular allowance |
429 Too Many Requests |
{"error": "capacity_unavailable", "message": "device session cap reached"} |
Device is at its per-device concurrent-session limit |
429 Too Many Requests |
{"error": "capacity_unavailable", "message": "customer session cap reached"} |
Account is at its concurrent-session limit |
503 Service Unavailable |
{"error": "capacity_unavailable", "message": "no browser host capacity in region"} |
No free browser host in the device's region |
503 Service Unavailable |
{"error": "capacity_unavailable", "message": "session changed during provisioning, retry"} |
Lost a provisioning race; retry |
500 Internal Server Error |
{"error": "server error"} |
Database or internal fault |
GET /v1/devices/{id}/browser
Retrieve the active browser session on a device (or 404 if there is none).
Authentication: Authorization: Bearer <api_key> (scope: proxies.read)
Path Parameters:
- id (UUID): device ID from /v1/devices
Request: (none)
Response: 200 OK
{
"session_id": "880e8400-e29b-41d4-a716-446655440000",
"status": "ready",
"connect_url": "wss://uswest1.aimlib.com/cdp/880e8400-e29b-41d4-a716-446655440000",
"egress_ip": "203.0.113.45",
"expires_at": "2026-06-07T15:30:00Z",
"started_at": "2026-06-07T14:55:00Z",
"resolved_profile": {
"android_version": "14",
"chrome_version": "126"
}
}
| Field | Type | Notes |
|---|---|---|
session_id |
UUID (string) | Browser session ID |
status |
string | Current session state (e.g., "idle", "provisioning", "ready", "expiring") |
connect_url |
string | WebSocket CDP-relay URL; connect only once status is "ready" |
egress_ip |
string or null |
Device public IP; null until the session is booted |
expires_at |
ISO 8601 timestamp | Hard deadline for the session |
started_at |
ISO 8601 timestamp | Present only once the session has started |
resolved_profile |
JSON object | Present only once a resolved profile exists; the effective profile after cloud processing |
Error Responses:
| Status | Body | Notes |
|---|---|---|
400 Bad Request |
{"error": "bad id"} |
Path id is not a valid UUID |
404 Not Found |
{"error": "session_not_found"} |
No active session on this device |
DELETE /v1/devices/{id}/browser
Stop the active browser session on a device. The session is moved to expiring and torn down asynchronously.
Authentication: Authorization: Bearer <api_key> (scope: sessions.write)
Path Parameters:
- id (UUID): device ID from /v1/devices
Request: (none)
Response: 200 OK
{
"status": "stopping"
}
Error Responses:
| Status | Body | Notes |
|---|---|---|
400 Bad Request |
{"error": "bad id"} |
Path id is not a valid UUID |
404 Not Found |
{"error": "session_not_found"} |
No active session on this device |
Rate Limiting
All /v1 requests share one rate-limit bucket per customer API key:
- Limit: 1000 requests per hour
- Key: the customer API key (not the source IP), so one customer's traffic never throttles another
Exceeding the limit returns 429 Too Many Requests with a Retry-After header; the body is produced by the rate-limit middleware.
Error Envelope
Most error responses use a two-field envelope:
{
"error": "<error_code>",
"message": "<human_readable_detail>"
}
Some basic errors use the short form (no message):
{
"error": "<error_message>"
}
Error codes are lowercase snake_case (e.g., unauthorized, forbidden, lease_inactive, profile_invalid, data_cap_exceeded, capacity_unavailable, session_not_found). Note that some short-form responses put a human-readable phrase directly in error (e.g., "bad id", "device not found", "server error").
Python SDK
The aimlib Python SDK is an async-only, typed wrapper over the customer REST surface (/v1) plus stock Playwright for driving a remote managed browser. It depends on httpx and playwright.
Installation
pip install "aimlib @ https://docs.aimlib.com/sdk/aimlib-0.2.0-py3-none-any.whl"
Quick Start
import asyncio
from aimlib import Aimlib
async def main():
async with Aimlib() as ai:
devices = await ai.devices.list()
device = devices[0]
print(f"Device {device.id}: {device.proxy.url}")
# Browser: ~55-60s cold-boot wait, then drive Chrome over CDP
async with await device.browser(profile="pixel-8-pro", ttl="10m") as b:
page = await b.new_page()
await page.goto("https://example.com")
print(f"Page title: {await page.title()}")
asyncio.run(main())
Aimlib Client
Constructor
ai = Aimlib(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 60,
)
Parameters
| Param | Type | Default | Notes |
|---|---|---|---|
api_key |
str | env AIMLIB_API_KEY |
Required. Format ak_live_*. Falls back to the environment variable. Sent as Authorization: Bearer <key>. |
base_url |
str | env AIMLIB_BASE_URL, else https://uswest1.aimlib.com |
The customer regional gateway base URL. |
timeout |
float | 60 |
Default HTTP request timeout, in seconds. |
Raises
- AimlibError if no API key is passed and AIMLIB_API_KEY is unset.
Environment variables
- AIMLIB_API_KEY — API key (used when api_key is not passed).
- AIMLIB_BASE_URL — regional gateway base URL (used when base_url is not passed).
Methods
async devices.list() -> list[Device]
List devices the caller holds an active lease on (GET /v1/devices).
devices = await ai.devices.list()
for device in devices:
print(f"{device.id}: {device.carrier} in {device.region}")
async devices.get(device_id: str) -> Device
Return a single leased device by ID. Implemented client-side over devices.list(). Raises AimlibError if the device is not leased to this account.
device = await ai.devices.get("my-device-id")
async device(device_id: str) -> Device
Shorthand for devices.get().
device = await ai.device("my-device-id")
async aclose()
Close the underlying HTTP client. Prefer the async context manager so this is called for you.
ai = Aimlib()
# ... use ai ...
await ai.aclose()
Async Context Manager
async with Aimlib() as ai:
devices = await ai.devices.list()
# ai.aclose() is called automatically on exit
Device Object
Represents a leased mobile device.
Attributes
| Attribute | Type | Notes |
|---|---|---|
id |
str | Device identifier (UUID). |
region |
str | None | Region (e.g. "uswest1"). |
carrier |
str | None | Active mobile carrier. |
current_egress_ip |
str | None | Current public IP of the device's mobile connection. |
proxy |
Proxy | None | The raw mobile proxy endpoint. Present for an active lease with a provisioned proxy. |
Methods
async browser(profile=None, ttl=None, idle_timeout=None, sticky=None) -> BrowserSession
Provision a managed stealth browser on this device (POST /v1/devices/{id}/browser). Returns an unconnected BrowserSession. Use async with await device.browser(...) as b to auto-connect and auto-stop.
Parameters
| Param | Type | Default | Notes |
|---|---|---|---|
profile |
str | Profile | dict | None | None |
Device profile and spoof overrides. A bare string is sent as {"device": <str>} (e.g. "pixel-8-pro", "galaxy-s24-ultra", "random"). A Profile dataclass or a raw dict is passed through (see below). |
ttl |
int | float | str | None | None |
Session lifetime. Accepts seconds (int/float) or a duration string ("30s", "5m", "1h"). Server clamps to 30 min max and to the lease end. |
idle_timeout |
int | float | str | None | None |
Idle release threshold; same formats as ttl. Server default when omitted is 120s. |
sticky |
bool | None | None |
Sent through as a boolean to the server when provided. |
Returns — BrowserSession (unconnected; call .connect() or use the async context manager to establish the CDP relay).
Raises
| Exception | When |
|---|---|
LeaseInactiveError |
No active lease with a proxy on this device (HTTP 403, lease_inactive). |
CapacityError |
Device/customer session cap or regional browser-host capacity reached (HTTP 429 / 503, capacity_unavailable). |
DataCapError |
Device monthly cellular cap reached (HTTP 409, data_cap_exceeded). |
AimlibError |
Invalid profile (HTTP 400, profile_invalid) or any other server/transport error. |
Examples
# Bare string -> {"device": "pixel-8-pro"}
session = await device.browser(profile="pixel-8-pro", ttl="10m")
# Profile dataclass
from aimlib import Profile
profile = Profile(
device="pixel-8-pro",
timezone="America/Los_Angeles",
locale="en-US",
cores=8,
ram_gb=6,
screen=(1440, 3120),
dpi=512,
)
session = await device.browser(profile=profile, ttl="5m")
# Raw dict (passed through; unknown keys are dropped server-side)
profile_dict = {
"device": "pixel-8-pro",
"model": "Pixel 8 Pro",
"brand": "Google",
"android_version": "15",
"chrome_version": "132",
"webgl": True,
"webrtc_block": True,
"battery": 85,
}
session = await device.browser(profile=profile_dict)
# Auto-connect and auto-stop
async with await device.browser(profile="pixel-8-pro", ttl="10m") as b:
page = await b.new_page()
# session auto-stops on __aexit__
Profile Dataclass
Typed device-profile overrides. A field left as None is omitted from the request and takes the profile default.
from aimlib import Profile
profile = Profile(
device="pixel-8-pro",
timezone="America/Los_Angeles",
locale="en-US",
cores=8,
ram_gb=6,
screen=(1440, 3120),
dpi=512,
)
Fields
| Field | Type | Default | Notes |
|---|---|---|---|
device |
str | None | None |
Standardized device name (e.g. "pixel-8-pro", "galaxy-s24-ultra", "random"). |
timezone |
str | None | None |
IANA timezone (e.g. "America/Los_Angeles"). |
locale |
str | None | None |
Locale (e.g. "en-US"). |
cores |
int | None | None |
Virtual CPU cores. |
ram_gb |
int | None | None |
Virtual RAM in GiB. |
screen |
tuple[int, int] | None | None |
Resolution (width, height); serialized as a JSON array. |
dpi |
int | None | None |
Display DPI. |
The
Profiledataclass exposes the seven typed fields above. To set additional spoof knobs, pass a raw dict todevice.browser(profile={...}).
Raw Dict Passthrough
When you pass a plain dict, the SDK forwards it as-is. The server bounds it (max 4 KiB, must be a JSON object) and strips any key not on the allow-list before it reaches the host. Allowed keys:
Spoof fields
device, timezone, locale, cores, ram_gb, screen, dpi, model, brand, manufacturer, android_version, build_fingerprint, security_patch, chrome_version
Behavioral flags
webgl, touch_points, tls_randomize, webrtc_block, ipv6_block, dns_leak_block, fonts, battery, audio_48khz, storage_quota
profile_dict = {
"device": "pixel-8-pro",
"manufacturer": "Google",
"build_fingerprint": "...",
"webrtc_block": True,
"dns_leak_block": True,
"battery": 75,
}
await device.browser(profile=profile_dict)
Proxy Object
The raw mobile proxy endpoint, available immediately for an active lease.
Attributes
| Attribute | Type | Notes |
|---|---|---|
id |
str | Proxy identifier. |
url |
str | Full proxy URL (<scheme>://username:password@host:port). Ready to use now. |
protocol |
str | Protocol (e.g. "http", "socks5"). |
status |
str | Status (e.g. "active"). |
device = (await ai.devices.list())[0]
proxy_url = device.proxy.url # use with httpx, requests, curl, etc.
BrowserSession Object
A managed browser session driving real Chrome over a Playwright CDP relay. It egresses through the same device mobile IP as the proxy.
Attributes
| Attribute | Type | Notes |
|---|---|---|
id |
str | Session identifier (UUID). |
device |
Device | The device this session runs on. |
status |
str | Server-reported state (e.g. "provisioning", "ready", "failed", "gone"). |
connect_url |
str | ws(s):// CDP relay endpoint. .connect() rewrites it to the HTTP discovery URL for Playwright. |
connect_token |
str | None | Bearer token for the CDP relay (sent as an Authorization header on connect). |
max_tabs |
int | Per-session tab cap (server-configured, default 5). |
egress_ip |
str | None | Device public IP for this session. Populated after the session is ready. |
fingerprint |
dict | Resolved profile (from the server resolved_profile). Populated after the session is ready; {} until then. |
browser |
Browser | None | Live Playwright Browser once connected; None before .connect(). |
Methods
async wait_until_ready(timeout: float = 180)
Poll until the session reaches "ready" (GET /v1/devices/{id}/browser). The container is booting; expect ~55-60s. Populates egress_ip and fingerprint.
Raises
- SessionExpiredError — session not found / expired (HTTP 404).
- AimlibError — session reported "failed" or "gone".
- SessionTimeout — not ready within timeout.
session = await device.browser(ttl="10m")
await session.wait_until_ready(timeout=180)
print(f"Ready at IP: {session.egress_ip}")
async connect(timeout: float = 180) -> Browser
Wait until ready, then connect over CDP. Starts Playwright and returns the live Browser (chromium.connect_over_cdp(...)).
Raises — same as wait_until_ready().
b = await session.connect(timeout=180)
page = await b.new_page()
await page.goto("https://example.com")
async new_page() -> Page
Open a tab. Auto-calls .connect() if not yet connected. Reuses the initial tab on the first call, then opens new tabs up to max_tabs.
Raises
- TabLimitError — open tabs would exceed max_tabs (raised client-side). Close a tab or start a separate session. Tabs share one IP and fingerprint.
async with await device.browser() as b:
page1 = await b.new_page() # reuses the initial tab
page2 = await b.new_page() # opens a 2nd tab
page3 = await b.new_page() # up to max_tabs
# await page.close() frees a slot
async stop()
Close the browser, stop Playwright, and DELETE /v1/devices/{id}/browser (marks the session for teardown). Best-effort: cleanup errors are swallowed.
await session.stop()
Async Context Manager
Auto-connects on entry, auto-stops on exit.
async with await device.browser(profile="pixel-8-pro", ttl="10m") as b:
page = await b.new_page()
await page.goto("https://example.com")
print(await page.title())
# session.stop() runs on __aexit__
Exceptions
All exceptions inherit from AimlibError. The SDK maps a typed server error code to its exception class; unmapped codes (and transport errors) raise the base AimlibError.
| Exception | Cause | Server error |
|---|---|---|
AimlibError |
Generic server/transport error, missing API key, device not leased, or invalid profile (profile_invalid). |
base class / unmapped |
CapacityError |
Device/customer session cap or regional browser-host capacity reached. | capacity_unavailable |
LeaseInactiveError |
No active lease with a proxy on the device. | lease_inactive |
DataCapError |
Device monthly cellular cap reached. | data_cap_exceeded |
SessionExpiredError |
Session not found / already expired. | session_expired |
SessionTimeout |
Session not "ready" within the timeout. |
session_provisioning |
TabLimitError |
Opening a tab would exceed max_tabs. |
raised client-side |
End-to-End Example
import asyncio
from aimlib import Aimlib, Profile
async def scrape_with_browser():
"""Pick a leased device, boot a stealth browser, scrape a page."""
async with Aimlib() as ai:
devices = await ai.devices.list()
if not devices:
print("No devices leased")
return
device = devices[0]
print(f"Using device {device.id} in {device.region}")
print(f"Proxy: {device.proxy.url}")
profile = Profile(
device="pixel-8-pro",
timezone="America/New_York",
locale="en-US",
cores=8,
ram_gb=8,
screen=(1440, 3120),
dpi=512,
)
print("Starting browser (~55-60s boot)...")
async with await device.browser(profile=profile, ttl="15m", idle_timeout="5m") as session:
print(f"Browser ready at IP: {session.egress_ip}")
print(f"Resolved profile: {session.fingerprint}")
page = await session.new_page()
await page.goto("https://example.com", timeout=30000)
print(f"Page title: {await page.title()}")
page2 = await session.new_page()
await page2.goto("https://httpbin.org/ip")
print(f"External IP (via browser): {await page2.text_content('body')}")
# session auto-stops on exit
print("Session stopped")
if __name__ == "__main__":
asyncio.run(scrape_with_browser())
Reference — Device Profiles, Limits & Errors
Device Profiles
A device profile is a set of transparent spoof knobs that customize the appearance of a browser session. Profiles are passed at session-creation time and are optional. The server sanitizes every profile before it reaches the host: it must be a JSON object, unknown keys are silently dropped, and oversized profiles are rejected.
Profile Keys
The following keys are on the server allow-list. Any other key is dropped (never forwarded to the host daemon):
| Category | Keys |
|---|---|
| Identity | device, model, brand, manufacturer, build_fingerprint |
| Display & Locale | timezone, locale, screen, dpi |
| Graphics | webgl, touch_points, chrome_version |
| System | cores, ram_gb, android_version, security_patch, storage_quota, battery |
| Network & Leak Protection | webrtc_block, ipv6_block, dns_leak_block, tls_randomize |
| Audio | audio_48khz |
| Typography | fonts |
Maximum profile size: 4 KiB (4096 bytes). Oversized profiles, non-JSON-object input, and unparseable JSON are all rejected with profile_invalid. Empty input defaults to {}.
Passing Profiles
Profiles are passed to device.browser(profile=...) in three forms:
-
Device name string (simplest) — sent as
{"device": "<name>"}:python session = await device.browser(profile="pixel-8-pro") # or "galaxy-s24-ultra", "random", etc. — aimlib standardized device names -
Profile object / dict — any allow-listed keys:
python session = await device.browser(profile={ "device": "pixel-8-pro", "timezone": "US/Pacific", "locale": "en-US", "cores": 8, "ram_gb": 12, "screen": [1440, 3120], "dpi": 512 }) -
SDK
Profileclass (typed):python from aimlib import Profile p = Profile(device="pixel-8-pro", timezone="US/Pacific", locale="en-US") session = await device.browser(profile=p)
The typed
Profiledataclass currently serializes onlydevice,timezone,locale,cores,ram_gb,dpi, andscreen. To set any other allow-listed key (e.g.webgl,build_fingerprint,battery), pass a plaindict.
Limits & Lifecycle
Session Limits
| Limit | Value | Notes |
|---|---|---|
| Max concurrent sessions per device | 1 (default) | Single-tenant-per-device guard; enforced at session creation. The per-device value is device.max_browser_sessions. |
| Max concurrent sessions per customer | 5 (default) | Tenant-wide cap; enforced at session creation and at auto-bind time. The per-customer value is customer.max_browser_sessions. |
| Max tabs per session | 5 (default) | Server-configured via the FARMCTL_MAX_TABS_PER_SESSION environment variable; returned to the SDK as max_tabs on session create. The SDK enforces it client-side (TabLimitError). Tabs in one session share the same IP and device fingerprint. |
| Session TTL ceiling | 30 minutes | Maximum session lifetime. A requested ttl is clamped to this ceiling and further clamped so a session never outlives its lease. |
| Default idle timeout | 120 seconds | Inactivity window before the session is auto-released. Override with idle_timeout. |
| Per-session data cap | 2 GB (default) | Cellular budget protecting SIM usage. Clamped down to the device's remaining monthly headroom; creation is refused with data_cap_exceeded if that headroom is already exhausted. |
| Cold-start estimate | ~55–60 s | Returned as ready_in_s on session create (measured ~55 s on the baked image; the API constant is 60). |
Lifecycle States
The status field on a session moves through:
- requested — initial state; host port slot claimed, awaiting provisioning.
- provisioning — container booting; not yet driveable (~55–60 s cold-to-driveable). The relay returns
session_provisioningforrequestedandprovisioning. - ready — container booted and driveable via the CDP relay.
- active — session in active use.
- idle — bound but not in use; eligible for adoption on the next
device.browser()call for the same (customer, device) pair. Still connectable via the relay. - expiring — marked for termination; the host has been signaled to tear down the container.
- gone — container released; no longer driveable.
- failed — provisioning failed; not recoverable.
The relay treats ready, active, and idle as connectable; requested/provisioning return session_provisioning; any other state returns session_expired (HTTP 410).
Adoption (Lazy Provisioning)
When the deployment has AutoBrowserSession enabled (FARMCTL_AUTO_BROWSER_SESSION, default on), a zero-cost idle session is pre-bound to each lease that has both a device and a proxy — no host, no container. On the customer's first device.browser() call for that device, the server adopts the idle row in place: it binds a host and port, mints the real connect token, and boots the container, instead of creating a second session. This is transparent to the SDK; the customer still just calls device.browser().
Errors
Typed errors are returned as a JSON body {"error": "<code>", "message": "..."}. The same code can map to more than one HTTP status depending on where it is raised (session create vs. the CDP relay).
| Error Code | HTTP Status | Cause | What to Do |
|---|---|---|---|
lease_inactive |
403 | No active lease with a proxy on this device, or the customer/lease is not active (checked at create and at relay connect). | Confirm the account is active and the device is currently leased with a live proxy. |
capacity_unavailable |
429 (device or customer cap) / 503 (no host capacity, or adopt race) | Per-device session cap reached, per-customer session cap reached, no browser-host capacity in the region, or the idle row changed during adoption. | Cap reached: stop a session or request another device / upgrade. No host capacity or adopt race: retry shortly, or try another region. |
data_cap_exceeded |
409 | The device's monthly cellular data cap is exhausted (SIM protection). | Wait for the monthly window to roll over or raise the device's monthly cap. |
session_expired |
403 (expired at relay) / 410 (session torn down — expiring/gone/failed) |
Session TTL exceeded or the session is no longer connectable. | Create a new session. |
session_provisioning |
425 | Session still provisioning (state requested/provisioning). Response includes Retry-After: 5 and retry_after: "5". |
Retry after Retry-After seconds. wait_until_ready() / connect() handle this automatically. |
profile_invalid |
400 | Profile is not a JSON object, is unparseable JSON, or exceeds 4 KiB. | Send a valid JSON object within the 4 KiB limit. |
session_not_found |
404 | No matching active session for this (customer, device), or the session id was not found at the relay. | Verify the device id; create a new session if needed. |
host_unavailable |
503 | The session has no host mesh IP or CDP port yet (host still starting / port not bound). | Retry; this is typically transient during host startup. |
Note: during
wait_until_ready(), the SDK also raisesSessionExpiredErrorlocally if aGET .../browserpoll returns 404 — this is a client-side mapping, not the APIsession_expiredcode.
SDK Exception Mapping
The Python SDK maps error codes to typed exceptions via _ERROR_BY_CODE in aimlib/__init__.py:
| Error Code | SDK Exception |
|---|---|
capacity_unavailable |
CapacityError |
lease_inactive |
LeaseInactiveError |
data_cap_exceeded |
DataCapError |
session_expired |
SessionExpiredError |
session_provisioning |
SessionTimeout |
Any unrecognized error code raises the base AimlibError. A 4xx/5xx response with no parseable error body raises AimlibError carrying the response text, or HTTP <status_code> if the body is empty. TabLimitError is raised by the SDK locally (it is not an API error code) when new_page() exceeds max_tabs.
Support
Submit issues, ask questions, and track them to resolution. A ticket is only complete once both you and the aimlib team have closed it — nothing is marked done while either side still considers it open. Posting any new message reopens a ticket.
Via the SDK
async with Aimlib() as ai:
# File a ticket
t = await ai.tickets.create(
subject="device.browser() times out",
body="On my device the session never reaches ready. Repro: ...",
)
print(t.id, t.status) # 'open'
# List your tickets
for t in await ai.tickets.list():
print(t.subject, t.status, "awaiting:", t.awaiting_close_from)
# View the full thread
t = await ai.tickets.get(t.id)
for m in t.messages:
print(f"[{m['author']}] {m['body']}")
# Reply (reopens the ticket if it was closed)
await ai.tickets.reply(t.id, "Tried a fresh key, same result.")
# Close from your side
t = await ai.tickets.close(t.id)
print(t.status, t.awaiting_close_from) # 'open', awaiting 'operator' until we close too
print(t.complete) # True only once BOTH sides have closed
Ticket object
| Attribute | Notes |
|---|---|
id |
Ticket ID |
subject |
Short title |
status |
"open" or "closed" (closed = both sides have closed) |
customer_closed / operator_closed |
Per-side close flags |
awaiting_close_from |
"customer", "operator", or "both" while open |
messages |
The thread: a list of {author, body, created_at} (author is "customer" or "operator") |
complete |
True only when status == "closed" |
REST
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/v1/tickets |
{"subject", "body"} |
File a ticket |
GET |
/v1/tickets |
— | Your tickets |
GET |
/v1/tickets/{id} |
— | Ticket + full message thread |
POST |
/v1/tickets/{id}/messages |
{"body"} |
Reply (reopens) |
POST |
/v1/tickets/{id}/close |
— | Close from your side |
All endpoints require Authorization: Bearer <api_key>.