outcrawl
The Python client for Outcrawl: scrape, crawl, search and run agent browser
tasks, on one credit balance. Pure Python — one py3-none-any wheel for every operating system,
and no compiler on any of them.
pip install outcrawl
Requires Python 3.10 or newer. One runtime dependency, httpx,
which is pure Python, as is everything it pulls.
Usage
import asyncio
from outcrawl import Outcrawl
async def main() -> None:
async with Outcrawl() as oc: # reads OUTCRAWL_API_KEY
doc = await oc.scrape("https://example.com", formats=["markdown"])
print(doc.markdown) # or doc["markdown"] — it is the wire object
print(doc.usage["credits"]) # "1" — what this call was charged
job = oc.agent(
task="Report the title of the top story on Hacker News.",
caps={"budget": "16.00"}, # the one ceiling a submit must carry; this task
# cost 5.0–7.7 credits on 2026-10-03 (n=33)
)
async for record in job: # the event cursor, while it runs
print(record["kind"], record["value"]) # payload shaped by kind: a step, a finding's text, ...
run = await job # and the settled run
print(run["status"], run.get("data")) # a "capped" run keeps any data it found first
asyncio.run(main())
await and async for on one job is one run and not two: they are separate requests against
one durable row, so watching a run and having its answer is not a choice.
Configuration
| Variable | Meaning | Default |
|---|---|---|
OUTCRAWL_API_KEY |
your key | required |
OUTCRAWL_API_URL |
base url | https://api.outcrawl.ai |
The same two names @outcrawl/sdk, the outcrawl CLI and the Outcrawl MCP server read. Pass
Outcrawl(api_key=..., base_url=...) to override them per client.
Surface
Every capability in the registry (46 today) is reachable, and tests/test_registry.py fails
when one is not.
-
oc.scrape(url, **options)/oc.crawl(url, **options)/oc.search(query, **options)—handle = await oc.crawl(url)answers a handle that is async-iterable and single-pass (async for page in handle).searchreturnsSearchHits: when one search engine refuses the query and another answers,hits.warningscarries anengines_refusedentry naming the engine that refused; when every engine refuses it raisesSearchRefusedError(503, retryable afterretry_after_seconds, a retry is served from another exit, nothing charged) rather than returning[]. -
Options are keyword arguments with the API's own names, sent as given;
help(oc.scrape)andhelp(oc.crawl)list every one with its default.maxAgeis how old a cached answer may be, in milliseconds, default172800000(2 days);maxAge=0is always live, neither reading nor writing the cache. A hit bills the samepage_loadcredit a live fetch does. -
doc.usage["credits"]is what the call was charged, as an exact decimal string ("1","4.5") — the same figure the ledger bills; read it withdecimal.Decimal, neverfloat. A scrape states it (a cache hit included), a search states the results plus the hits it rendered, and a crawl states it on every page and for the whole crawl on its closing frame (handle.job["usage"]["credits"]once iteration ends). An agent run states its charge as the run's owncredits. -
Every scrape result and crawl page says where it came from:
doc.cacheStateis"hit","miss"or"bypass",doc.cachedAtis when a hit was captured,doc.usage["cache"]["status"]agrees with it (HIT/MISS/DISABLED),doc.metadata["servedVia"]is"direct"or"residential", the network path that fetched it, anddoc.metadata["egressCountry"]is the country (ISO code) of the address it was fetched from.doc.metadata["exitRetries"](absent unless it happened) is how many times the page was fetched again from a fresh exit inside yourtimeoutMs:1when the exit's connection never opened, the site answered it with a reduced page, or the exit answered nothing under the page;2at most, only when the first connection never opened. Still onepage_load.doc.metadata["readFrom"](absent unless it happened) is"served"when the content was extracted from the page's HTML as the server sent it, before its scripts ran: a markdown/html/json request whose page body already held its main content when it arrived. SetwaitForfor the rendered page. -
A degraded page still comes back, and says so in
doc["warnings"]: absent on a clean result, otherwise a list of{"code", "format"?, "message"}whosemessagenames the numbers.partial_document— the page was read before its main document finished loading (the parser was still in it, or the response was still arriving or had stopped), somarkdown,htmlandrawHtmlare a prefix of the page; retry with a largertimeoutMsor awaitFor, or, when the message says the response stopped arriving (already retried once on a fresh exit when time allowed), retry as is.empty_capture— the requestedmarkdownorhtmlcame back empty.blocked_interstitial— a bot-protection page came back instead of the page.challenge_budget_exhausted— a challenge left too little time to finish reading.source_truncated— a JSON or text body arrived cut off.json_no_content,json_emptyandjson_required_emptydescribe ajsonextraction: no page text, no data, or empty values for fields your schema requires.egress_country_mismatch— the page is not the one a visitor in the country you asked for sees: either this destination cannot be reached through our residential network, so the page was fetched from our own address inmetadata["egressCountry"], or the site placed our exit in another country, which the message names, also after one fresh exit; prices and availability may differ.search_challenge_unsolved— a search engine's results page came back as its "verify you are human" check rather than results; useoc.search(...)(POST /v1/search) for search results.degraded_document— the URL names one item (today an Amazon product page) and the site answered with a reduced page without it (recommendations and footer only); it is fetched again, then once from a fresh exit, whentimeoutMsallows, so a result still carrying it is a failed fetch.login_redirect— the site redirected the request to its sign-in page and that page came back instead (the message names where); treat it as a failed fetch.render_incomplete— the page was rendered and was not whole when read (its loads were still running when the render budget ran out, or its text changed on the confirming re-read);metadata.completeisfalse.robots_unchecked— a crawl page from a host whose robots.txt could not be read (5xx or timeout); the crawl went on as if the site had no rules. -
oc.crawl.job(id)— the same crawl by id, withstatus(),results(),cancel(), andoc.crawl.get(id)/.results(id)/.cancel(id)for an id on its own. A streaming crawl belongs to its connection, so hanging up cancels it: the row settlescancelledwith every page it had already delivered, and these routes are how you read the pages you paid for. The id is onhandle.job["id"]from the first progress frame — store it before you need it. -
Once a crawl's iteration ends,
handle.failureslists the URLs it could not fetch (a message startingsite_refusedorsite_rate_limitedwith the site'sstatusCode,robots_disallowed, or the network error) andhandle.warningscarriescrawl_incompletewhen it settledcompletedshort of itslimitbecause pages failed (messagenames delivered/limit,countsthe causes). Both are[]otherwise;oc.crawl.get(id)answers the samewarnings. -
oc.agent(task=..., caps=...)—capsis required at runtime andcaps.budgetis required inside it;caps.stepsandcaps.durationare optional and unset unless you name them, and all three are hard stops when present. The job is awaitable, async-iterable, and carries everything a handle does:status(),results(),events(),cancel(),control(),answer(),add_file().oc.agent.get(id)and friends reach a run by id — an id from a webhook needs no submit.schemaaccepts a JSON Schema object and constrains the run'sdatato it, the same typed submit@outcrawl/sdkdocuments. -
oc.profiles/oc.secrets/oc.rules/oc.integrations/oc.sessions/oc.monitors. -
Session reads, for a
/connectsession or an agent run (scrape, crawl and search record nothing):oc.sessions.events(id, kind=..., tab=..., after=..., follow=...)andoc.sessions.network(id, url=..., status="5xx", min_duration_ms=..., bodies=True, follow=...)are async iterators whose.sourcesaysschema(v1/v2) and thegapsan older recording never carried;oc.sessions.network.digest(id),oc.sessions.request(id, request_id),oc.sessions.console(id),oc.sessions.har(id),oc.sessions.videos(id)andoc.sessions.share(id, visibility="private" | "link" | "people", people=[...])(private to your team by default;privaterevokes and rotates the link).oc.agent.list(status=..., since=...)lists your runs. A session read or share refused for the key raisesReplayAccessDeniedError(replay_access_denied, 403,details["reason"]):operator_keymeans the key was issued by Outcrawl staff, and such keys never open a customer's recordings; use a key made in the console.oc.secretshas deliberately noget: there is no route to widen. A run reaches a value only by naming its HANDLE in the submit, and the substitution happens below the model.oc.secrets.intent(name=..., kind=...)is for a value a person types into YOUR web page, so it never passes through your server: your app server calls it and getsname,kind,url,tokenandexpiresAt; handurlandtokento the page, which sendsPUT urlwithAuthorization: Bearer <token>and a body{"value": "..."}. The token is single use, lasts five minutes and writesnameand nothing else (creating it, or replacing the value already stored under it). It is a credential: give it to that page only and never log it; the result'sreprmasks it. A refusedPUTanswerssecret_intent_invalid(401, the token does not open that URL for that name) orsecret_intent_spent(410,details.reasonusedorexpired), rebuilt here asSecretIntentInvalidError/SecretIntentSpentError; nothing is stored either way and the fix is a new intent. Minting is capped per account per minute (QuotaExceededErrorwithceiling == "secret_intents_per_minute"). A value your server already holds goes throughoc.secrets.create. -
oc.integrations— MCP servers a run may call outside the browser. Connecting is not granting: a run reaches a connector only when its submit names it (oc.agent(integrations=["linear"])).verdict = await oc.integrations.test( url="https://mcp.linear.app/mcp", auth={"kind": "bearer", "secret": "linear_key"} ) if not verdict["ok"]: print(verdict["reason"], verdict["message"]) # not_mcp | unauthorized | unreachable | missing_secret
test(url=, auth=)checks a server and stores nothing; a check that ran always answers, andoksays how it went.connect(...)runs the same check first and raisesIntegrationCheckFailedError(422,.reasonfrom the same four) with nothing stored.update(id, tools=..., auth={"secret": ...})changes the tool pin or the secret handle in place;tools=Nonemeans every tool the server offers, and a new secret is checked the same way before it replaces the old one.list()rows carrylastUsedAt, when a run last called the connector successfully (Noneif never). Each call a run makes appears in its event log as a record withkind: "integration"whosevalueis{id, name, tool, ok, ms}, or{id, name, status: "unreachable", reason, message}for a granted connector the run could not use; both are at the defaultsteplevel. No tool arguments or results are logged. -
oc.usage(**query)andoc.credits()— every credit figure is a decimal string; usedecimal.Decimal, neverfloat(). -
CAPABILITY_AVAILABILITY— whether anything is SERVED behind a declared route. A capability nothing serves is refused in your own process, with what is missing, rather than as a 503.
Errors
Identical to the TypeScript SDK's: same classes, same fields, same sentences. Branch on
error.code, never on str(error).
from outcrawl import ProfileInUseError, QuotaExceededError
try:
...
except ProfileInUseError as leased:
retry_at = leased.held_until # a retry that knows when to retry
except QuotaExceededError as over:
await asyncio.sleep(over.retry_after_seconds)
tests/error_parity.json is emitted from the TypeScript SDK's own error reconstruction and read by
both test suites, so neither surface can drift from the other without turning its own suite red.
What is not here
oc.browser(). The live browser needs a CDP connection driven in the caller's own process, which
is why it is deliberately not a registry capability and why the TypeScript SDK is the one place it
exists. The same page capabilities are reachable through agent and scrape, which run the loop
on our side. That is a difference in kind, not an omission.
The same reason takes Session.take_control with it: the TypeScript SDK's takeControl() opens a
CDP connection to a running session's own page so a person can drive it directly, and that
connection has to live in the caller's process for the same reason oc.browser() does. Everything
else on a session — get, list, export, live() — is a row read or a held stream and is here.
Development
pip install -e '.[dev]' # pytest + pytest-asyncio, never runtime dependencies
python3 scripts/gen_registry.py # regenerate registry.py from packages/core/src/registry.ts
python3 scripts/gen_registry.py --check # or just fail if it is stale
bun ../sdk-python/scripts/gen_error_parity.ts # regenerate the shared error contract
python3 -m pytest tests -q # the drift, parity and ergonomics tests
From the repo root, npm run test:python runs the staleness check and the suite together on
the SUPPORTED FLOOR — uv run --extra dev --python 3.10 pytest — so the gate exercises 3.10
rather than whatever python3 happens to be. Without uv: pip install -e '.[dev]' and
python3 -m pytest tests -q.
The two registry-versus-TypeScript tests skip when run from an unpacked sdist, which has no TypeScript to compare against, and run whenever the monorepo is present. The reachability test — the one that fails when a registry row has no method — has no such dependency and always runs.
Metadata
Release files for outcrawl 0.4.8
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| outcrawl-0.4.8.tar.gz | 103.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| outcrawl-0.4.8-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 180.9 kB
Release files / outcrawl-0.4.8.tar.gz
| Download URL | outcrawl-0.4.8.tar.gz |
|---|---|
| Size | 103.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
182c2dcf8ff2e18a8088f4263e4df63de68efe7a1bb26af3bb68d775a3d76420
|
|
BLAKE2b-256 checksum How to use checksums |
ed2b7dd7cf4f656d54a74bbd4c7f91db48820cae277ea3f26d31c5f819873962
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / outcrawl-0.4.8-py3-none-any.whl
| Download URL | outcrawl-0.4.8-py3-none-any.whl |
|---|---|
| Size | 77.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7c4a3908d7a7ae50fd68739cef68d43d3e84c2836384a1563efba6aba55a1afc
|
|
BLAKE2b-256 checksum How to use checksums |
0d5f0cf6c567e18167ea45d6f8a28f6d1132905ea40f8059152570a7e33e7a24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Security for outcrawl 0.4.8
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.