---
title: "How Actors work · Scrape.ly Docs"
description: "Lifecycle, input schema, versions, builds, runs, and Standby."
url: https://scrape.ly/docs/actors
markdown: https://scrape.ly/docs/actors.md
---

# How Actors work

> Lifecycle, input schema, versions, builds, runs, and Standby.

An Actor is a program that runs in a container on Scrape.ly. You write the code and an input schema; the platform builds the image, runs it on demand or on a schedule, stores the results, and bills the compute.

```js [JavaScript]
import { Actor } from 'scrapely';

await Actor.main(async () => {
    const input = await Actor.getInput();
    await Actor.pushData({ received: input });
});
```

```python [Python]
import asyncio

from scrapely import Actor


async def main():
    actor_input = await Actor.get_input()
    await Actor.push_data({"received": actor_input})


if __name__ == "__main__":
    asyncio.run(Actor.main(main))
```

`Actor.main()` initializes the SDK, runs your function, and exits with the right status. Don't also wrap the callback in `async with Actor:`.

## What is an Actor?

One unit of automation: usually a scraper, but any job works, such as enriching records, monitoring prices, or processing files.

| Part | What it is |
| --- | --- |
| Source code | JavaScript, TypeScript, or Python, using the [JavaScript SDK](https://scrape.ly/docs/sdk-node.md) (`scrapely`) or the [Python SDK](https://scrape.ly/docs/sdk-python.md) (`scrapely`) |
| Actor config | `actor.json`: name, version, schemas, memory limits, environment variables |
| Versions | Each with its own source and environment variables |
| Builds | Docker images made from a version |
| Runs | Executions of a build with one input |
| Settings | Defaults and switches you set in the dashboard or API: run defaults, Standby, permissions, the Scrape.ly browser |

> [!WARNING]
> The Python SDK is preinstalled in the Scrape.ly Python base images. Don't `pip install scrapely`: the PyPI package with that name is an unrelated library.

## Project layout

The platform uses the first Actor config it finds:

1. `.actor/actor.json`
2. `.actor/scrapely.json`
3. `scrapely.json`
4. `actor.json`

If a project has both `.actor/` config and a root `scrapely.json`, fields in the root file override the `.actor/` ones.

```json [.actor/actor.json]
{
    "actorSpecification": 1,
    "name": "my-actor",
    "title": "My Actor",
    "version": "0.0",
    "buildTag": "latest",
    "input": "./input_schema.json",
    "storages": { "dataset": "./dataset_schema.json" },
    "environmentVariables": { "API_KEY": "@myApiKey" },
    "minMemoryMbytes": 512,
    "maxMemoryMbytes": 4096
}
```

### Config fields

| Field | Type | Description |
| --- | --- | --- |
| `actorSpecification` | number | Always `1` |
| `name` | string | Actor name. `scrapely push` creates the Actor with it |
| `title`, `description` | string | Used when `scrapely push` creates the Actor |
| `version` | string | `MAJOR.MINOR` version that `scrapely push` uploads to |
| `buildTag` | string | Tag for successful builds of this version. Default `latest` |
| `input` (alias `inputSchema`) | path or object | [Input schema](#input) |
| `output` (alias `outputSchema`) | path or object | [Output schema](#output-schema) |
| `storages` | object | [Storage schemas and extra storages](#storage-schemas) |
| `environmentVariables` | object | Variables for the version. A value starting with `@` refers to a [secret](https://scrape.ly/docs/cli-webhooks-secrets.md#secrets) |
| `minMemoryMbytes`, `maxMemoryMbytes` | number | Run memory is clamped to this range |
| `usesStandbyMode` | boolean | `true` turns on [Standby](#standby) at build time if the Actor has no Standby config yet |
| `dockerfile` | path | Dockerfile to build. Default `.actor/Dockerfile`, then `Dockerfile` |
| `dockerContextDir` | path | Docker build context, relative to the config. Can point above the Actor folder in a Git monorepo |
| `readme`, `changelog` | path | Default `.actor/README.md`, then `README.md` |

Without `input`, the platform looks for `.actor/input_schema.json`, `.actor/INPUT_SCHEMA.json`, `input_schema.json`, `INPUT_SCHEMA.json`, then `schema.json`. Paths resolve relative to the config file.

> [!NOTE]
> `defaultMemoryMbytes`, `webServerSchema`, and `webServerMcpPath` are accepted so existing Actor configs keep working, but have no effect on Scrape.ly today. Set the default memory in the Actor's [settings](#actor-settings) instead.

Check every schema the config references before you push:

```bash
scrapely validate-schema
```

## Lifecycle

1. The platform starts your container and injects the run environment: run token, run ID, default storage IDs, timeout, memory, proxy settings, and `CDP_URL` for the [Scrape.ly browser](https://scrape.ly/docs/browser.md). See [JavaScript environment](https://scrape.ly/docs/sdk-node-environment.md) and [Python environment](https://scrape.ly/docs/sdk-python-environment.md).
2. `Actor.init()`, which `Actor.main()` calls for you, connects storage, events, and charging.
3. Your code reads input, does the work, and writes to [storage](https://scrape.ly/docs/storage.md).
4. `Actor.exit()` flushes pending writes and exits. If the process crashes, the run is `FAILED` and its log is kept.

The SDK doesn't start an HTTP server. If you need one, for [Standby](#standby) for example, listen on the port the platform gives you.

## Input

Input is the JSON object your Actor gets at the start of a run. The input schema describes it and drives the dashboard form:

```json [input_schema.json]
{
    "title": "Listing scraper input",
    "type": "object",
    "schemaVersion": 1,
    "properties": {
        "startUrl": {
            "title": "Start URL",
            "type": "string",
            "description": "Where the crawl begins",
            "editor": "textfield",
            "prefill": "https://example.com"
        },
        "maxItems": { "title": "Max items", "type": "integer", "default": 50, "minimum": 1 },
        "proxyConfiguration": {
            "title": "Proxy",
            "type": "object",
            "editor": "proxy",
            "default": { "useScrapelyProxy": true }
        }
    },
    "required": ["startUrl"]
}
```

| Field | Dashboard control |
| --- | --- |
| `string` | Text input. A password input when `isSecret` is `true` or `format` is `password` |
| `integer`, `number` | Number input |
| `boolean` | Toggle |
| Any field with `enum` | Dropdown |
| `array` | Repeating list of items |
| `object` with `properties` | Nested group of fields |
| `"editor": "proxy"` | Managed-proxy toggle or custom proxy URLs. See [Proxy](https://scrape.ly/docs/proxy.md) |
| `"editor": "requestListSources"` | List of start URLs, saved as `[{ "url": "..." }]` |
| `"editor": "hidden"` | Not shown |

Other `editor` values are accepted and fall back to the control for the field's type. Users can always switch to a raw JSON editor.

- `"isSecret": true` masks the value in saved input and encrypts it in the run's stored input. See [Security](https://scrape.ly/docs/security.md#secret-input-fields).
- `"editor": "resourcePicker"` with a `resourceType` lets a [limited-permission](https://scrape.ly/docs/security.md) Actor access the storage IDs a user passes in.
- An input schema can be up to 500 KB.
- The form shows fields in the order `properties` lists them, so put the important ones first.

### Group fields into categories

An Actor with many options can keep the form short: leave the 1–3 main inputs ungrouped and put the rest into collapsible categories. Categories only change how the form looks. They don't change the input, defaults, validation or runs.

```json [input_schema.json]
{
    "title": "Google Search Scraper",
    "type": "object",
    "schemaVersion": 1,
    "categories": {
        "filters": {
            "title": "Search filters",
            "description": "Narrow results by site, words and dates.",
            "defaultCollapsed": true
        },
        "dates": { "title": "Date range", "parent": "filters" },
        "ads-related": { "title": "Related to Ads", "defaultCollapsed": true }
    },
    "properties": {
        "queries": { "title": "Search queries", "type": "string", "editor": "textarea" },
        "site": { "title": "Site", "type": "string", "editor": "textfield", "category": "filters" },
        "afterDate": { "title": "After date", "type": "string", "editor": "textfield", "category": "dates" },
        "liveAiOverview": { "title": "Live AI overview", "type": "boolean", "category": "ads-related" }
    },
    "required": ["queries"]
}
```

Here `queries` is the main input, `Search filters` starts collapsed, and `Date range` sits inside it.

| Key | Where | Type | Effect |
| --- | --- | --- | --- |
| `categories` | Schema root | object | Map of category key → settings. Categories appear in the order you declare them |
| `title` | Category | string | Section heading. Defaults to the category key |
| `description` | Category | string | Shown under the heading |
| `defaultCollapsed` | Category | boolean | `true` starts the section collapsed. Default `false`. `collapsed` and `default_contracted` are accepted aliases |
| `parent` | Category | string | Key of the category this one sits inside |
| `category` | Property | string | Key of the category the field belongs to. Top-level properties only |

- Fields without a `category` come first, then each category with its fields in schema order, then its nested categories.
- Categories nest up to 3 levels deep. A deeper category is shown at level 3. A `parent` that loops back (`a` → `b` → `a`) or names no category puts the category at the top level.
- A category with no fields, directly or through nested categories, isn't shown.
- A schema can define up to 100 categories. With more, `categories` is ignored and every field is shown ungrouped.
- Each section header shows how many fields it holds and how many are set to a non-default value. A section opens on its own when one of its fields has an error or is required and empty.
- `sectionCaption` and `sectionDescription` also work: a field with a caption starts a collapsed section that runs to the next caption. These sections come after your categories. A field with a `category` ignores `sectionCaption`.
- A `category` that names no defined category doesn't fail the build. The build log warns, with the closest key (`unknown category "ad-related". Did you mean "ads-related"?`), and the field is shown ungrouped. `scrapely validate-schema` treats it as an error, along with parent loops, nesting deeper than 3 and wrong types, and warns about unused categories.

> [!WARNING]
> The dashboard prefills each field from `default`, then `prefill`. The API doesn't apply schema defaults or check `required`, so a run started over the API gets exactly the input it was sent. Apply defaults and validation in your code.

Read input with `Actor.getInput()` / `Actor.get_input()`, or `getInputOrThrow()` / `get_input_or_throw()` when missing input should fail the run.

## Output

Results go to storage, not to files in the container:

| Storage | Use it for |
| --- | --- |
| Dataset | Result items: `Actor.pushData()` / `Actor.push_data()` |
| Key-value store | Files and single records. `OUTPUT` is the usual key for a single result |
| Request queue | The crawl frontier |

Each run has its own default dataset, key-value store, and request queue. See [Storage](https://scrape.ly/docs/storage.md).

### Storage schemas

`storages` in the config attaches schemas to a run's storages and can create extra ones:

```json
{
    "storages": {
        "dataset": "./dataset_schema.json",
        "keyValueStores": {
            "default": "./kvs_schema.json",
            "screenshots": "./screenshots_schema.json"
        }
    }
}
```

| Key | Effect |
| --- | --- |
| `dataset`, `keyValueStore`, `requestQueue` | Schema for the run's default storage of that type |
| `datasets`, `keyValueStores`, `requestQueues` | Map of alias → schema. `default` is the default storage. Every other alias gets its own storage for each run, as long as the map has a `default` entry |

- Open an extra storage by alias: `Actor.openKeyValueStore({ alias: 'screenshots' })` (JavaScript) or `Actor.open_key_value_store(alias="screenshots")` (Python). The aliases reach the run in `ACTOR_STORAGES_JSON`.
- A dataset schema with `fields` (JSON Schema draft-07) validates every pushed item. If one fails, nothing is stored and the push returns `400 schema-validation-error`. See [Datasets API](https://scrape.ly/docs/api-datasets.md).

### Output schema

An output schema tells users where a run's results are. Each property's `template` becomes a link on the run's **Output** tab and in the run object's `output` map:

```json [output_schema.json]
{
    "actorOutputSchemaVersion": 1,
    "title": "Output",
    "properties": {
        "results": {
            "type": "string",
            "title": "Results",
            "template": "{{links.apiDefaultDatasetUrl}}/items"
        },
        "screenshots": {
            "type": "string",
            "title": "Screenshots",
            "template": "{{storages.keyValueStores.screenshots.apiUrl}}/keys"
        }
    }
}
```

| Template variable | Value |
| --- | --- |
| `links.apiDefaultDatasetUrl`, `links.apiDefaultKeyValueStoreUrl` | API URL of the run's default dataset or key-value store |
| `links.apiRunUrl`, `links.consoleRunUrl`, `links.publicRunUrl` | The run in the API and the dashboard |
| `storages.datasets.<alias>.apiUrl` | API URL of a dataset alias. Same for `keyValueStores` and `requestQueues` |
| `run.<field>` | Any field of the run object, for example `run.id` |

Unknown variables resolve to an empty string. The dashboard shows dataset links as a table and key-value store `keys` or `records/<key>` links as their contents.

## Versions

An Actor has one or more versions, numbered `MAJOR.MINOR` (`0.0`, `0.1`, `1.0`). Each version has:

- A source: uploaded files, a GitHub repository, or a tarball URL (up to 500 MB). Git sources accept `https://github.com/...` and `git@github.com:...` URLs only. A version's stored source can hold up to 10,000 files and 128 MB; see [Versions](https://scrape.ly/docs/api-actors.md#versions) for the upload limits.
- Environment variables, plain or [secret](https://scrape.ly/docs/security.md)
- A build tag (default `latest`) for its successful builds

`scrapely push` uploads the current directory as the source of the version in your config.

```
GET /v2/actors/:actorId/versions
POST /v2/actors/:actorId/versions
GET /v2/actors/:actorId/versions/:versionNumber
PUT /v2/actors/:actorId/versions/:versionNumber
DELETE /v2/actors/:actorId/versions/:versionNumber
GET /v2/actors/:actorId/versions/:versionNumber/env-vars
```

`GET` returns the version with its file contents, so you can read a version, change a field, and `PUT` it back without touching the source. See the [Actors API](https://scrape.ly/docs/api-actors.md#versions).

## Builds

A build turns one version's source into a Docker image. Only the owner can build. Builds start from:

- `scrapely push`
- The dashboard, on the Actor's **Builds** tab
- The API: `POST /v2/actors/:actorId/builds?version=0.0`
- The Actor's CI webhook, for Git versions. It pulls the latest commit first, and the build reads `actor.json` and the input schema from that commit.

### Base images

Without a Dockerfile, the platform generates one: Node.js projects install `package.json` dependencies and start `main.js`, Python projects start `main.py`. With your own Dockerfile, start `FROM` a Scrape.ly base image, which already contains the SDK:

| Image | Versions |
| --- | --- |
| `scrapely/actor-node`, `-playwright`, `-playwright-chrome`, `-playwright-firefox`, `-playwright-webkit`, `-playwright-camoufox`, `-puppeteer-chrome` | Node.js 20, 22, 24, 26 |
| `scrapely/actor-python`, `-playwright`, `-playwright-chrome`, `-playwright-firefox`, `-playwright-webkit`, `-playwright-camoufox`, `-selenium` | Python 3.11, 3.12, 3.13, 3.14 |

Tag the image with the version, for example `scrapely/actor-node-playwright-chrome:22`. Builds also accept the matching `apify/actor-*` images, but publishing to the Store requires a `scrapely/actor-*` base.

> [!TIP]
> Browser images ship the browsers your Actor launches inside its container. To run them on the hosted [Scrape.ly browser](https://scrape.ly/docs/browser.md) instead, turn it on in the Actor's settings and rebuild.

### Options and limits

Pass options in the request body as `{"options": {...}}`:

| Option | Default | Notes |
| --- | --- | --- |
| `memoryMbytes` | 4096, or your plan's maximum if lower | A power of two |
| `timeoutSecs` | Your plan's maximum | Can't exceed it |
| `noCache` | `false` | Rebuild every layer |

Build memory counts toward your account's total memory limit. Your plan's limits are under **Billing** → **Limits**.

> [!NOTE]
> Files under `/tmp` don't survive the build's layer cache. A `COPY` or `ADD` into `/tmp` and everything after it in that stage always rebuild, but a `RUN` that leaves files in `/tmp` for a later `RUN` can fail on a cached rebuild. Keep build inputs outside `/tmp`. See [Start a build](https://scrape.ly/docs/api-actors.md#start-a-build).

### Statuses and tags

- A build goes `READY` → `RUNNING` → `SUCCEEDED`, `FAILED`, `TIMED-OUT`, or `ABORTED`.
- A successful build gets its version's build tag (`latest` by default). `latest` always belongs to the newest succeeded build by start time (ties go to the higher build number), not to the build that finished last: a build that finishes after a newer one already holds `latest` stays untagged. If the holder leaves `SUCCEEDED`, `latest` moves back to the newest succeeded build. You can add your own tags: 1 to 64 letters, digits, `.`, `_`, and `-`, starting with a letter or digit.
- Runs use `latest` unless you pass `build` or the Actor has a default build. Other users can't pick a build: they always run the [User Build](https://scrape.ly/docs/publishing.md#user-build).
- Each build records whether it uses the Scrape.ly browser (`browserPatched`).

```
GET /v2/actors/:actorId/builds
POST /v2/actors/:actorId/builds
GET /v2/actors/:actorId/builds/default
GET /v2/actor-builds/:buildId
POST /v2/actor-builds/:buildId/abort
GET /v2/actor-builds/:buildId/log
```

Builds, build logs, and versions are owner only, also for public Actors.

## Actor settings

Per-Actor settings live on the Actor's **Settings** tab in the dashboard and on the Actor object in the API. Only the owner can change them.

| Dashboard card | Setting | API field | Default |
| --- | --- | --- | --- |
| Default run options | Default build | `defaultRunOptions.build` | `latest` |
| | Memory | `defaultRunOptions.memoryMbytes` | 2048 MB |
| | Timeout | `defaultRunOptions.timeoutSecs` | 3600 s |
| | Maximum cost per run | `defaultRunOptions.minimalMaxTotalChargeUsd` | None |
| | Restart on error | `defaultRunOptions.restartOnError` | `false` |
| | (API only) | `defaultRunOptions.maxItems`, `defaultRunOptions.forcePermissionLevel` | None |
| Continuous integration | GitHub webhook URL and workflow | [CI webhooks](https://scrape.ly/docs/api-actors.md#ci-webhooks) | |
| Integrations | Example input body and content type | `exampleRunInput` | None |
| Standby Mode | Enable, memory, idle timeout, build, requests per instance | `actorStandby` | Off. See [Standby](#standby) |
| Actor permissions | Limited or full | `actorPermissionLevel` | `LIMITED_PERMISSIONS`. Locked once published. See [Security](https://scrape.ly/docs/security.md#actor-permissions) |
| Scrapely Browser | Use Scrapely browser | `PUT /v2/actors/:actorId/browser-patch` | Off. Applies to the next build. See [Scrape.ly Browser](https://scrape.ly/docs/browser.md) |

Store listing fields (title, description, SEO, categories, icon, pricing, source visibility) and the [User Build](https://scrape.ly/docs/publishing.md#user-build) (the build other users run, `defaultNonOwnerBuild`) are on the **Publication** tab. See [Publish and monetize](https://scrape.ly/docs/publishing.md).

## Runs

A run executes one build with one input, and needs at least one successful build. Start runs from:

- The dashboard
- The CLI: `scrapely call` for a platform run, `scrapely run` for the local project on your machine
- The API: `POST /v2/actors/:actorId/runs`
- [Tasks and schedules](https://scrape.ly/docs/integrations.md)

Statuses: `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `ABORTING`, `ABORTED`, `TIMING-OUT`, `TIMED-OUT`.

### Run options

Query parameters on `POST /v2/actors/:actorId/runs`. Anything you leave out falls back to the Actor's `defaultRunOptions`, then the platform default:

| Option | Default | Notes |
| --- | --- | --- |
| `timeout` | 3600 seconds | A positive whole number of seconds, or `400 invalid-timeout` |
| `memory` | 2048 MB | A power of two (128, 256, 512, …) within your plan's limits and the Actor's `minMemoryMbytes`/`maxMemoryMbytes` |
| `build` | `latest` | A build tag or number. Ignored on someone else's Actor, which always runs the [User Build](https://scrape.ly/docs/publishing.md#user-build). |
| `maxItems` | None | Passed to the run as `ACTOR_MAX_PAID_DATASET_ITEMS`. Result-item events past it aren't billed, so your code should stop at this count |
| `maxTotalChargeUsd` | Actor's `minimalMaxTotalChargeUsd` | Spending cap for the run: usage, browser credits, [LLM gateway](https://scrape.ly/docs/llm-gateway.md) requests, and pay-per-event charges. See [Publish and monetize](https://scrape.ly/docs/publishing.md) |
| `restartOnError` | `false` | `true` or `1` restarts the run if it fails |
| `forcePermissionLevel` | Actor's level | `LIMITED_PERMISSIONS` runs any Actor with limited permissions |
| `waitForFinish` | 0 | Wait for the run to finish before responding. Accepts up to 60; the wait stops at 45 seconds |
| `webhooks` | None | Base64-encoded JSON array of ad hoc webhooks for this run |

Send an `Idempotency-Key` header to make a retried start return the same run instead of starting another. See [Idempotent run starts](https://scrape.ly/docs/api-actors.md#idempotent-run-starts).

The request body is the run's input.

| Response | Meaning |
| --- | --- |
| `201` | Run created |
| `202` | Queued for capacity, with `meta.queuePosition` and `meta.estimatedWaitSecs` |
| `429 concurrency-limit-exceeded` | You're at your concurrent run limit |
| `400 limit-exceeded` | The run would exceed your total memory |

`run-sync` and `run-sync-get-dataset-items` start a run and wait for it, for at most 45 seconds. After that they return `408` with the run ID, and the run keeps going. Without `timeout`, they cap the run at 300 seconds. See [Waiting for runs and builds](https://scrape.ly/docs/api-overview.md#waiting-for-runs-and-builds).

Read a run's log with `GET /v2/actor-runs/:runId/log` or `scrapely runs log <runId>`. See the [Runs API](https://scrape.ly/docs/api-runs.md).

## Standby

A Standby Actor keeps a container warm and serves HTTP requests instead of batch runs:

```
ALL /v2/:username/:actorName/*
```

Callers send their own Bearer token and are billed for the run. The Actor must be public, or the caller must own it. Configure it under `actorStandby` or on the **Standby Mode** settings card:

| Setting | Default |
| --- | --- |
| `isEnabled` | `false` |
| `desiredRequestsPerActorRun` | 100 |
| `maxRequestsPerActorRun` | None |
| `idleTimeoutSecs` | 300 |
| `build` | `latest`. Other users' standby runs use the [User Build](https://scrape.ly/docs/publishing.md#user-build) when it's set. |
| `memoryMbytes` | 2048 |

Listen on `ACTOR_WEB_SERVER_PORT` and bind to `0.0.0.0`. Read the port with `Actor.getEnv().containerPort` (JavaScript) or `Actor.get_env().container_port` (Python). Standby runs have `ACTOR_META_ORIGIN=STANDBY` and `ACTOR_STANDBY_URL`. See [Standby API](https://scrape.ly/docs/api-standby.md) and the [JavaScript Standby guide](https://scrape.ly/docs/sdk-node-standby.md).

## Publishing

To list an Actor in the Store it needs a title, a description, a README, a category, a successful build, and an input schema. A Dockerfile must use a `scrapely/actor-node` (Node.js 20, 22, 24, or 26) or `scrapely/actor-python` (Python 3.11 or later) base. Most Actors go live immediately; some get a short manual review first. Set pay-per-event pricing and earn 80% of what users pay with purchased credits, paid out when you request it.

See [Publish and monetize](https://scrape.ly/docs/publishing.md).

## Full example

```js
import { Actor, CheerioCrawler } from 'scrapely';

await Actor.main(async () => {
    const { startUrl = 'https://news.ycombinator.com', maxItems = 25 } =
        (await Actor.getInput()) ?? {};

    const proxyConfiguration = await Actor.createProxyConfiguration();

    const crawler = new CheerioCrawler({
        proxyConfiguration,
        maxRequestsPerCrawl: maxItems,
        requestHandler: async ({ $, request }) => {
            await Actor.pushData({
                url: request.url,
                title: $('title').text().trim(),
            });
        },
    });

    await crawler.run([startUrl]);
});
```

Push it with `scrapely push` and run it with `scrapely call`. The results land in the run's default dataset, ready to export from the dashboard, the CLI, or the API.

---

- Previous: [Learning path](https://scrape.ly/docs/academy.md)
- Next: [Storage](https://scrape.ly/docs/storage.md)
- All docs: [Docs index](https://scrape.ly/docs.md), [llms.txt](https://scrape.ly/llms.txt)
