---
sidebar_label: Limits
toc_max_heading_level: 3
doc_id: b45d7d17-d4cf-412a-8eba-f0d8f7c3c7d4
description: >-
  How long a nullplatform workflow step can run and how much data can move
  through a run, the errors you get when you cross a budget, and the ways to
  stay under it.
keywords:
  - workflows
  - limits
  - timeouts
  - payload size
  - execution state
  - logs
  - outputProjection
  - nullplatform
---

# Limits

Every run has two kinds of budget: how long each step may take, and how many bytes may move through it. They exist because the engine is shared, and one workflow that fetches everything shouldn't be able to slow down everyone else's.

Crossing a budget is always explicit. The step fails with a stable error code, the measured size, and the name of the setting that raises it, so you never have to guess which limit you hit. Logs are the one exception: they get trimmed rather than failed, and every trim leaves a marker saying so.

## How long a run and each step can take

There is no limit on how long a run can stay open. A run waiting on `signal-wait`, on a Jira transition, or on a person answering in Slack consumes nothing while it waits and survives restarts, so a run that stays open for days or weeks is normal.

The limit applies to each individual step:

| Step | Default | Ceiling | Set by |
|---|---|---|---|
| Any step | 30 minutes | none | `step.metadata.executionTimeoutMs` |
| `code-exec`, inline | 30 seconds | 2 minutes | platform policy |
| `code-exec`, isolated runtime | 60 seconds | 10 minutes | platform policy |
| AI agent step | the step's own iteration budget | 1 hour | `maxIterations` on the step |

A step also has to keep reporting progress: if 5 minutes pass without a progress report, the engine assumes the worker is stuck and kills the attempt. Steps retry 3 times by default, which you tune per node with `error_handling.retry_policy`.

Two rows need an explanation. You don't set a `code-exec` step's timeout: the platform does, and it also picks the runtime for you. Declare `network` hosts or npm `libraries` in the node and the step runs in the isolated runtime, with the longer budget. And when a step would need more than 30 minutes, split the work into many short steps instead of raising the timeout: 30 minutes is plenty for an API call and never enough for scanning a whole fleet.

On a very large run, the limit you hit first is rarely the clock. It's the amount of data the run accumulates, covered in the next section.

## How much data can move through a run

```mermaid
flowchart LR
    TRIG["Trigger payload<br/>1 MB"] --> STEP["Each step's output<br/>4 MB, up to 16 MB"]
    STEP --> STATE["Accumulated state<br/>16 MB, up to 64 MB"]
    STEP --> NEXT["Handed to the next step<br/>2 MB"]
    STEP --> CODE["code-exec boundary<br/>1 MB in, 1 MB out"]
    STEP --> AI["AI agent prompt<br/>the model's context window"]

    classDef np fill:#e6faf4,stroke:#00b894,color:#0b3d2e;
    classDef infra fill:#eef3fb,stroke:#274a86,color:#14243d;
    class TRIG,STEP,STATE,NEXT np;
    class CODE,AI infra;
    linkStyle default stroke:#94a3b8,stroke-width:1.5px;
```

| What | Default | Raise it with | Hard maximum |
|---|---|---|---|
| Trigger payload, and any API body | 1 MB | not configurable | 1 MB |
| A single step's outputs and items | 4 MB | `step.metadata.maxStepOutputBytes` | 16 MB |
| What a step is handed to run: the items and outputs forwarded from the steps before it | 2 MB | not configurable | 2 MB |
| Execution state, meaning every variable and step output combined | 16 MB | `metadata.maxStateBytes` on the workflow | 64 MB |
| What a `code-exec` step receives, and what it returns | 1 MB each | not configurable | 1 MB each |
| An AI agent's prompt | the model's context window | n/a | the model's context window |

Watch the `code-exec` limit first. It's four times tighter than the 4 MB a step is otherwise allowed, so a code step that receives the result of a broad data lake query fails there long before any other budget. Reduce the query result before it reaches the code, not inside it.

Execution state is the other common cause, because it's cumulative. No single step has to be large: a run that loops two hundred times and appends to a variable on each pass reaches the 16 MB limit on its own.

The 2 MB hand-off matters when a step's own output is within budget. A step can return 3 MB, under its 4 MB budget, and still fail the step after it, because what that next step receives is the result plus everything the engine packs alongside it. When a heavy step feeds another, trim at the source rather than at the consumer.

## How much of your logs is kept

Log budgets work differently from the ones above. Crossing one never fails a step, it trims what gets kept, and the trim is always explicit: a truncated log says it was truncated instead of just ending.

| Where | What's kept | Past that |
|---|---|---|
| A step's report back to the engine | 900 KB | `logs`, `outputs`, `inputs`, and `variables` are swapped for a truncation marker, largest field first, until the report fits. Status, error, and timestamps always survive |
| One `code-exec` step | 1,000 entries, 1 MB total | Further entries are dropped, with a marker naming the cap that was hit |
| One AI agent step | the last 200 entries, 16 KB each and 8 KB for a tool call's payload | Older entries collapse into one marker counting what was dropped. The complete transcript stays in the agent's own session |

Everything a step prints reaches the run's log view through that report, so the 900 KB ceiling is the one that determines what you can read back. There is no separate retention cap on a finished run: if a step's logs fit its report, they are all there.

In practice, logs are for following a run, not for storing its results. If a number still matters after the run ends, return it in the step's outputs or write it somewhere queryable rather than printing it.

## The errors you get when you cross a limit

Four error codes cover the size limits. Each one names the step and the number it measured:

- **`STEP_OUTPUT_TOO_LARGE`**: one step returned more than its output budget. The message carries the byte count and the limit it was measured against.
- **`STEP_INPUT_TOO_LARGE`**: what a step was about to receive, the items and outputs forwarded from the steps before it, crossed the 2 MB hand-off budget. It's reported on the receiving step, but the excess comes from the step before it, and the message says so.
- **`EXECUTION_STATE_TOO_LARGE`**: the accumulated variables and step outputs crossed the workflow's state budget. The message names the step that tipped it over, which is where it was noticed, not necessarily where the data came from.
- **`HISTORY_LIMIT_EXCEEDED`**: a very large fan-out grew the run's internal history past what the engine can keep. Nothing failed on its own; steps that completed kept their results. This is the error that ends thousand-item runs, and the fix is fanning out into sub-workflows rather than raising a limit.

None of the four retries: the same payload would be just as large on the next attempt.

## How to stay under the limits

Try these in order. Raising a limit comes last because the budgets are generous for anything a workflow should hold in memory at once, so crossing one usually means the run is carrying data it doesn't need rather than the ceiling being too low.

**Keep only the fields you use.** A step can declare `outputProjection`, a whitelist of dot paths applied before the result is measured or recorded. Everything else is dropped at the source:

```yaml
- id: fetch_scopes
  type: module
  plugin_type: np-api-call
  outputProjection:
    - body.results[].id
    - body.results[].name
    - body.results[].status
```

Paths are dot-separated, and a `[]` suffix maps the projection over an array. Missing paths are ignored, so it's safe to project optional fields.

**Fetch less.** Set `maxRows` on data lake queries, select the columns you need instead of `*`, and page through large collections with `paginated-fetch` or `np-entity-paginated-fetch` rather than pulling everything into one step.

**Split the work.** `split-in-batches` walks a large array in chunks, and a `sub-workflow` per item gives each child its own state budget instead of accumulating everything into the parent. This is the answer for genuinely large fan-outs, and the only real answer to `HISTORY_LIMIT_EXCEEDED`.

**Keep large data outside the run.** When a workflow has to handle more data than a run should carry, don't move the data through the run. Write it where it belongs, as catalog metadata on the entity, as rows something else can query, or into your own storage with an `http-request` step, and pass a reference between steps instead of the payload. The cost right-sizing suite works this way: a scope's cost history lives in its catalog instance, and the run carries only the scope id.

**Raise the limit last.** Once you've done the above and the payload can't get any smaller, `step.metadata.maxStepOutputBytes` and the workflow's `metadata.maxStateBytes` take you to 16 MB and 64 MB respectively. Values above the ceiling are clamped rather than rejected.

```yaml
- id: collect_inventory
  type: module
  plugin_type: np-api-call
  metadata:
    maxStepOutputBytes: 8388608
```

## Next steps

- [Loops and iteration](/docs/workflows/building-blocks/loops): splitting a large sweep so it fits
- [Items and data flow](/docs/workflows/building-blocks/items-and-data-flow): why a fan-out is what makes a run large
- [Nodes](/docs/workflows/building-blocks/nodes): `split-in-batches`, `sub-workflow`, and the retry policy
- [Expressions](/docs/workflows/building-blocks/expressions): how step outputs reach the next step
- [Runs and versions](/docs/workflows/building-blocks/runs-and-versions): reading a failed run and finding the step that broke
