> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Log export

> Start and poll an asynchronous organization-wide NDJSON export of agent logs, and download its gzipped parts from signed links

A **log export** is the drain's offline twin. The same rows go through the same event builder and the same [scrubber](/drains/protecting-your-destination#what-is-masked-before-anything-leaves) as a [log drain](/drains/overview); instead of being pushed at a destination you own, they are written as gzipped NDJSON and handed back as signed, expiring links.

Use it when you have no collector, or when you need a window that predates the drain — a drain copies what happens next, and an export reads what already happened.

These routes are organization-scoped and need `logs:read`. The base URL, bearer authentication, and the error envelope are on the [REST API overview](/reference/rest/overview); [`lua logs export`](/reference/cli/logs#lua-logs-export) is the same surface from a terminal.

<Info>
  Exports are switched on for a deployment as a whole. While they are off, both routes answer `404` — the resource does not exist rather than being forbidden. **It may not be enabled on your deployment yet.**
</Info>

## Scopes

| Operation                   | Scope                             |
| --------------------------- | --------------------------------- |
| Start an export, read a job | `logs:read`                       |
| `includeContent: true`      | `logs:read` **and** `logs:manage` |

`logs:manage` is **sensitive**: a wildcard such as `logs:*` does not satisfy it. It is checked when the job is started, not when it is read — the decision is frozen onto the job at that moment, because the worker picks the job up minutes later with no request and no principal behind it. A job started without content cannot be upgraded; start another.

Both routes share the drain [rate-limit buckets](/reference/rest/log-drains#rate-limits): `log-drains-mutate` on the start, `log-drains-read` on the poll.

## POST /admin/orgs/:orgId/logs/export

Starts an export. Returns immediately; the work happens on the platform.

<ParamField body="from" type="string" required>Inclusive lower bound: an ISO 8601 instant, or a relative form such as `15m`, `2h`, or `7d`. A relative form is resolved by the **server's** clock.</ParamField>
<ParamField body="to" type="string" required>Inclusive upper bound; the same formats.</ParamField>
<ParamField body="sources" type="string[]">Which [sources](/drains/overview#sources) to export. Defaults to every exportable source.</ParamField>
<ParamField body="agents" type="string[]">Up to 200 agent ids. Defaults to every agent in the organization.</ParamField>
<ParamField body="environments" type="string[]">Any of `production`, `sandbox`. Defaults to both.</ParamField>
<ParamField body="includeContent" type="boolean" default="false">Include `user_message` and `agent_response` bodies. Requires `logs:manage`.</ParamField>

**Response**

`202` with `{ "jobId": "lex_…", "state": "queued", "createdAt": "…" }`. Poll the status route.

**Errors**

| Status | Code                        | When                                                 |
| ------ | --------------------------- | ---------------------------------------------------- |
| `400`  | `DRAIN_VALIDATION_FAILED`   | The body is malformed; `field` names the offender    |
| `403`  | `EXPORT_CONTENT_FORBIDDEN`  | `includeContent: true` without `logs:manage`         |
| `409`  | `EXPORT_ALREADY_RUNNING`    | The organization already has one queued or running   |
| `422`  | `EXPORT_WINDOW_INVALID`     | A bound will not parse, or `from` is later than `to` |
| `422`  | `EXPORT_WINDOW_TOO_LARGE`   | The range is longer than 31 days                     |
| `422`  | `EXPORT_SOURCE_UNSUPPORTED` | A source an export cannot produce — see below        |

<Note>
  **Only the eighteen execution-log sources and `execution` are exportable.** The six [adjacent-record sources](/drains/overview#sources) — `delivery`, `trigger-execution`, `job-execution`, `workflow-run`, `voice-call`, and `workflow-audit` — are built for a drain out of the platform's own ledgers and are not execution-log rows, so there is nothing for an export to read. Naming one answers `422 EXPORT_SOURCE_UNSUPPORTED`. To capture them, point a drain at a destination you can read.
</Note>

<CodeGroup>
  ```bash CLI theme={null}
  lua logs export --since 7d --out ./export
  ```

  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/logs/export', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ from: '7d', to: 'now', sources: ['skill', 'job', 'execution'] }),
  });
  const { jobId } = await response.json();
  ```

  ```bash cURL theme={null}
  curl -sS -X POST "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/logs/export" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "from": "2026-09-01T00:00:00Z", "to": "2026-09-22T00:00:00Z", "sources": ["skill"] }'
  ```
</CodeGroup>

## GET /admin/orgs/:orgId/logs/export/:jobId

Reads one job. A job id belonging to another organization answers `404`, never `403` — the two cases are deliberately indistinguishable.

**Response**

```json theme={null}
{
  "id": "lex_7c1e0b44f9a2d538",
  "orgId": "<<YOUR_ORG_ID>>",
  "state": "succeeded",
  "window": { "from": "2026-09-15T00:00:00.000Z", "to": "2026-09-22T00:00:00.000Z" },
  "selectors": { "sources": ["skill", "job", "execution"], "environments": ["production"], "includeContent": false },
  "counts": { "scanned": 4182230, "rows": 4180117, "scrubHits": 92, "files": 3, "bytes": 190418221 },
  "files": [
    {
      "seq": 1,
      "name": "org-acme-logs-0001.ndjson.gz",
      "rows": 1600000,
      "bytes": 67108864,
      "fileId": "fil_9a1c…",
      "url": "https://cdn.heylua.ai/…?X-Amz-Expires=86400&…"
    }
  ],
  "createdAt": "2026-09-22T09:00:11.004Z",
  "startedAt": "2026-09-22T09:00:16.221Z",
  "finishedAt": "2026-09-22T09:07:44.918Z",
  "expiresAt": "2026-09-23T09:07:44.918Z",
  "deleteAfter": "2026-09-29T09:07:44.918Z"
}
```

| Field              | Meaning                                                                                   |
| ------------------ | ----------------------------------------------------------------------------------------- |
| `state`            | `queued`, `running`, `succeeded`, `failed`, or `expired`                                  |
| `counts.scanned`   | Rows read. On a job that was resumed after an interruption this slightly **over**-counts  |
| `counts.rows`      | Records actually written. Exact, and the number the files hold                            |
| `counts.scrubHits` | How many values the scrubber masked                                                       |
| `truncated`        | Present and `true` when the job hit the row cap. A **success with a flag**, not a failure |
| `files[].url`      | A signed link, present only while the job is `succeeded` and inside `expiresAt`           |
| `expiresAt`        | When the signed links stop working — 24 hours after the job finished                      |
| `deleteAfter`      | When the files are removed — 7 days after the job finished                                |
| `error`            | On `failed`, a short scrubbed reason                                                      |

**Errors**

| Status | Code               | When                                     |
| ------ | ------------------ | ---------------------------------------- |
| `404`  | `EXPORT_NOT_FOUND` | No job with that id in this organization |

## Caps

| Cap                                 | Value                                                          |
| ----------------------------------- | -------------------------------------------------------------- |
| Window                              | 31 days                                                        |
| Rows per job                        | 10,000,000 — beyond it the job succeeds with `truncated: true` |
| Agents named per job                | 200                                                            |
| Concurrent exports per organization | **1**                                                          |
| Signed URL lifetime                 | 24 hours after the job finishes                                |
| File lifetime                       | 7 days after the job finishes                                  |
| Compressed bytes per part           | up to 256 MB; parts are cut well below it                      |

**One export runs per organization at a time.** That is a guarantee, not a race — a second start answers `409 EXPORT_ALREADY_RUNNING`. `lua logs export` turns that into an attach: a second invocation in a terminal follows the running job to its files rather than refusing.

A **truncated** export is a success. The files are valid and complete as far as they go; they simply do not cover the whole window. Narrow the window or the sources and run it again.

## What the files contain

**One complete batch per line.** Not one record per line.

A record has no resource of its own — `service.name`, `lua.org.id`, `deployment.environment.name`, and the agent version all live on the batch's `resource` half. A file of bare records would validate against nothing you were given and would drop the tenancy every record depends on.

Parts are named `org-<orgId>-logs-<NNNN>.ndjson.gz`, zero-padded so lexical order is emission order:

```bash theme={null}
cat org-acme-logs-*.ndjson.gz | gunzip > window.ndjson

# every record id in a part
gunzip -c org-acme-logs-0001.ndjson.gz | jq -r '.records[].id'
```

### Validate it against the published schema

Each line is one batch and validates against the same [JSON Schema](https://docs.heylua.ai/schemas/logs/1.0) a drain delivery does — byte-identical shape, same encoder.

```bash theme={null}
npm i -g ajv-cli
curl -sS https://docs.heylua.ai/schemas/logs/1.0 -o lua-log-batch.schema.json

gunzip -c org-acme-logs-0001.ndjson.gz \
  | while IFS= read -r line; do
      printf '%s' "$line" > /tmp/batch.json
      ajv validate -s lua-log-batch.schema.json -d /tmp/batch.json --errors=text || exit 1
    done && echo "all lines valid"
```

Build your ingest so an unknown `attributes` key is carried through rather than rejected — additions are [additive within a major version](/drains/event-schema#versioning), and a schema addition should never fail a load.

### What is not in them

* **Rows written before organization attribution existed.** A row that never recorded an organization cannot be attributed to one, so it is in no org-scoped export. These are the oldest rows only, and the boundary is fixed: nothing written since carries the gap.
* **`log`-type rows with no source.** A row the platform never classified is counted in `counts.scanned` and not in `counts.rows`.
* **Message bodies**, unless the job was started with `logs:manage` and `includeContent: true`. The record still arrives, carrying `lua.content.withheld: true`.
* **Anything the scrubber caught.** Scrubbing is unconditional: there is no flag that skips it, and `includeContent: true` does not weaken it. An export applies the built-in rule classes (`builtin`, `lua-token`, `vendor-key`); organization scrub rules live on a *drain*, so they do not apply here.

### Retention bounds what you can export

The platform keeps execution logs for [90 days by default](/concepts/security-and-data#retention). An export can only read what is still there, so a window whose lower bound is older than the retention horizon returns the part of it that survives, not an error. Export before you need to, not after.

## The CSV download is deprecated

`GET /admin/orgs/:orgId/logs/download` still works and still caps at 10,000 rows. It is deprecated in favour of this job:

|           | CSV download                            | Export job                                                                             |
| --------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
| Rows      | 10,000, silently truncated              | 10,000,000, and it says when it truncated                                              |
| Shape     | A flat 9-column CSV                     | The published [event schema](/drains/event-schema), byte-identical to a drain delivery |
| Window    | Any, but you still only get 10,000 rows | ≤ 31 days, complete                                                                    |
| Delivery  | One synchronous response                | An asynchronous job with signed links                                                  |
| Scrubbing | None                                    | The full built-in rule set, unconditional                                              |

Nothing about the CSV route has changed. Its removal will be its own change, with its own release note.

## See also

* [`lua logs export`](/reference/cli/logs#lua-logs-export) — the same job from a terminal, with the parts downloaded for you
* [Ship logs to your stack](/drains/overview) — the streaming half, for everything that happens next
* [Object storage](/drains/object-storage) — a continuous archive into a bucket you own
* [Event schema](/drains/event-schema) — every field on every line
* [Log drains REST](/reference/rest/log-drains) — the drain routes, which share this error envelope
