> ## 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 drains

> Organization routes that create, inspect, verify, test, pause, and rotate log drains, plus the time-window and cursor parameters on the log read routes

These routes configure [log drains](/drains/overview) — the rules that copy agent execution logs to a destination you own — and read the platform's own copy of those logs. Drain routes are organization-scoped and need `logs:manage`; the read routes need `logs:read`. The base URL, bearer authentication, and the error envelope are on the [REST API overview](/reference/rest/overview); `lua drains` is the same surface from a terminal.

<Info>
  Log drains are switched on for a deployment as a whole, not per organization. While they are off, every drain route on this page answers `404` — the resource does not exist rather than being forbidden, which is why a `404` here never distinguishes a disabled feature from an unknown drain.
</Info>

## Scopes

`logs:manage` is **sensitive**: a wildcard such as `logs:*` does not satisfy it, and a scoped key holds it only when it was granted by exact name. `logs:read` is an ordinary read scope — a key that already holds `*:read` has it and needs nothing re-minted.

| Family              | Read          | Write         |
| ------------------- | ------------- | ------------- |
| Drain configuration | `logs:manage` | `logs:manage` |
| Deliveries          | `logs:manage` | —             |
| Log records         | `logs:read`   | —             |

A credential without the scope answers `403` with `code: "INSUFFICIENT_SCOPE"` and the `requiredScope`.

Every drain route resolves the drain by organization **and** id together, so a drain id from another organization answers `404 DRAIN_NOT_FOUND` rather than `403`. The two cases are deliberately indistinguishable.

## Rate limits

The drain routes are limited **per organization**, not per credential — a drain is an organization resource, and every route carries the organization in its path:

| Requests                    | Limit          |
| --------------------------- | -------------- |
| Reads (`GET`)               | 300 per minute |
| Mutations (everything else) | 60 per minute  |

Over either, the request answers `429` with `code: "DRAIN_RATE_LIMITED"` and a bare `Retry-After` header in seconds. Back off for what it says and retry.

Every response, throttled or not, also carries the bucket's own budget headers, suffixed with the bucket that was evaluated — `log-drains-read` on a `GET`, `log-drains-mutate` on everything else:

| Header                                  | Meaning                             |
| --------------------------------------- | ----------------------------------- |
| `X-RateLimit-Limit-log-drains-read`     | The bucket's ceiling for the minute |
| `X-RateLimit-Remaining-log-drains-read` | What is left of it                  |
| `X-RateLimit-Reset-log-drains-read`     | Seconds until the window rolls      |

Read the bare `Retry-After` to back off and the suffixed `X-RateLimit-Remaining-*` to pace yourself; a client that looks for an unsuffixed `X-RateLimit-Remaining` will not find one.

`POST …/verify` carries a second, narrower limit on top of this one — 5 attempts per drain per hour, answered as `429 DRAIN_VERIFY_RATE_LIMITED`. The [log read routes](#reading-logs) are limited separately, and per credential.

## Drains

### GET /admin/orgs/:orgId/log-drains

Lists every drain in the organization, including health and quota. Soft-deleted drains are not returned.

**Response**

`200` with `{ "drains": LogDrainRead[], "limits": { "maxDrains": 5 } }`. `limits.maxDrains` is the organization's ceiling — 5, or 10 on Enterprise — so a UI can say "3 of 5" without knowing the plan. Each drain carries its `health` and `quota` blocks.

`headers[]` carries each header's `name` and `last4` and never a value; `last4` is an empty string when the stored value is shorter than 8 characters, rather than a shortened value. `secret` is never present.

<CodeGroup>
  ```bash CLI theme={null}
  lua drains list --json
  ```

  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains', {
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>' },
  });
  const { drains } = await response.json();
  console.log(drains.map((d: { name: string; state: string }) => `${d.name}: ${d.state}`));
  ```

  ```bash cURL theme={null}
  curl -sS "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### POST /admin/orgs/:orgId/log-drains

Creates a drain. It starts in `pending_verification` and buffers rather than delivers until ownership is proved.

<ParamField body="name" type="string" required>1–64 characters, unique in the organization.</ParamField>
<ParamField body="type" type="string" required>`http`, `otlp`, `datadog`, or `betterstack`.</ParamField>
<ParamField body="endpoint" type="string">The destination URL. Required for `http`, `otlp`, and `betterstack`; refused for `datadog`, whose URL is derived from `site`. HTTPS only, and never a private, loopback, link-local, or cloud-metadata address.</ParamField>
<ParamField body="site" type="string">`datadog` only. One of `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`, `ddog-gov.com`.</ParamField>
<ParamField body="format" type="string" default="json">`json` or `ndjson`. Honoured for `http`; the other types fix their own encoding.</ParamField>
<ParamField body="headers" type="object[]">Up to 10 `{ name, value }` pairs. **Write-only**: values are stored encrypted and never returned. `name` matches `^[A-Za-z0-9-]{1,64}$`.</ParamField>
<ParamField body="secret" type="string">**Write-only** HMAC signing secret for an `http` drain. Generated when omitted, and returned exactly once in this response.</ParamField>
<ParamField body="sources" type="string[]">The [sources](/drains/overview#sources) to copy. Defaults to every source except `user_message` and `agent_response`.</ParamField>
<ParamField body="minSeverity" type="string" default="info">`debug`, `info`, `warn`, or `error`.</ParamField>
<ParamField body="agents" type="string | string[]" default="*">`"*"` for every agent in the organization, now and later, or a list of agent ids that must belong to this organization.</ParamField>
<ParamField body="environments" type="string[]" default="[&#x22;production&#x22;]">Any of `production`, `sandbox`.</ParamField>
<ParamField body="sampling" type="object">`{ "debug": 0.1 }`. Deterministic head sampling by execution, `0 < n <= 1`. Only `debug` is sampleable.</ParamField>
<ParamField body="includeContent" type="boolean" default="false">Turns on the `user_message` and `agent_response` bodies.</ParamField>
<ParamField body="includeContentAck" type="boolean">Required `true` when `includeContent` is `true`.</ParamField>
<ParamField body="redact" type="string[]">Up to 32 attribute keys to drop before encoding, for example `["user.id"]`.</ParamField>
<ParamField body="scrubRules" type="object[]">Up to 20 organization masking rules, `{ id, pattern, flags? }`. Each pattern is at most 256 characters, with no backreferences, nested quantifiers, or lookbehind.</ParamField>

**Response**

`201` with the created drain. **This is the only response that ever carries `secret`.** Store it now: it cannot be read back, only rotated.

**Errors**

| Status | Code                         | Meaning                                                               |
| ------ | ---------------------------- | --------------------------------------------------------------------- |
| `400`  | `DRAIN_VALIDATION_FAILED`    | A field is malformed; `field` names it                                |
| `409`  | `DRAIN_NAME_TAKEN`           | Another drain in the organization has that name                       |
| `422`  | `DRAIN_LIMIT_REACHED`        | 5 drains already exist (10 on Enterprise)                             |
| `422`  | `DRAIN_TYPE_UNAVAILABLE`     | That `type` is not available yet                                      |
| `422`  | `DRAIN_ENDPOINT_INVALID`     | Wrong scheme, or a private, loopback, link-local, or metadata address |
| `422`  | `DRAIN_ENDPOINT_NOT_ALLOWED` | The host is not one this destination type accepts                     |
| `422`  | `DRAIN_CONTENT_ACK_REQUIRED` | `includeContent` without `includeContentAck`                          |
| `422`  | `DRAIN_SCRUB_RULE_INVALID`   | A scrub rule fails the pattern limits                                 |
| `422`  | `DRAIN_AGENT_NOT_IN_ORG`     | An id in `agents` is outside this organization                        |

<CodeGroup>
  ```bash CLI theme={null}
  lua drains create --ci --json \
    --name "Datadog prod" --type datadog --site datadoghq.eu \
    --header-from-env DD-API-KEY=DATADOG_API_KEY \
    --environments production
  ```

  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: 'Datadog prod',
      type: 'datadog',
      site: 'datadoghq.eu',
      headers: [{ name: 'DD-API-KEY', value: process.env.DATADOG_API_KEY }],
      sources: ['skill', 'mcp', 'execution', 'agent_error'],
      minSeverity: 'info',
      environments: ['production'],
    }),
  });
  const created: { id: string; state: string; secret?: string } = await response.json();
  console.log(created.id, created.state);
  ```

  ```bash cURL theme={null}
  curl -sS -X POST "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Log receiver",
      "type": "http",
      "endpoint": "https://logs.example.com/lua",
      "minSeverity": "info",
      "environments": ["production"]
    }'
  ```
</CodeGroup>

### GET /admin/orgs/:orgId/log-drains/:drainId

Reads one drain, with `health`, `quota`, and the last `verification` outcome.

**Response**

`200` with a `LogDrainRead`:

```json theme={null}
{
  "id": "drn_2b7f10d9ac4e83615702ffab",
  "orgId": "org_4f2c9a1b",
  "name": "Datadog prod",
  "type": "datadog",
  "endpoint": "https://http-intake.logs.datadoghq.eu/api/v2/logs",
  "site": "datadoghq.eu",
  "format": "json",
  "headers": [{ "name": "DD-API-KEY", "last4": "9f3c" }],
  "selectors": {
    "sources": ["skill", "mcp", "execution", "agent_error"],
    "minSeverity": "info",
    "agents": "*",
    "environments": ["production"],
    "includeContent": false
  },
  "state": "healthy",
  "verifiedAt": "2026-09-20T11:02:44.117Z",
  "verification": { "attemptedAt": "2026-09-20T11:02:44.002Z", "outcome": "ok", "statusCode": 202, "latencyMs": 115 },
  "health": {
    "lastSuccessAt": "2026-09-21T09:14:02.881Z",
    "lastFailureAt": "2026-09-21T02:11:07.430Z",
    "lastStatusCode": 202,
    "backlog": 0,
    "backlogBytes": 0,
    "deliveredCount24h": 418223,
    "droppedCount24h": 0,
    "rejectedCount24h": 0,
    "p50LatencyMs": 138,
    "scrubHits24h": 17,
    "hourly": [
      { "h": 497194, "delivered": 16044, "dropped": 0, "rejected": 0 },
      { "h": 497195, "delivered": 17311, "dropped": 0, "rejected": 12 },
      …
      { "h": 497217, "delivered": 9042, "dropped": 0, "rejected": 0 }
    ]
  },
  "quota": {
    "eventsPerDay": 10000000,
    "bytesPerDay": 10737418240,
    "usedEvents": 418223,
    "usedBytes": 289417216,
    "resetsAt": "2026-09-22T00:00:00.000Z",
    "degradation": "none"
  },
  "version": 4,
  "createdBy": { "type": "user", "id": "usr_1f70b2" },
  "createdAt": "2026-09-20T10:58:12.004Z",
  "updatedAt": "2026-09-21T09:14:02.881Z"
}
```

`health.hourly` is the same 24 hours as the `*24h` counters, broken into points to draw. There are always **24** of them, oldest first and zero-filled across quiet hours, so nothing has to handle a gap: `h` is the absolute UTC hour (`floor(epochMilliseconds / 3600000)`), and `delivered`, `dropped`, and `rejected` are that hour's attempt counts. The window is the current, partial hour and the 23 before it, which is why the newest point's numbers keep rising while you watch them.

**Errors**

| Status | Code              | Meaning                                    |
| ------ | ----------------- | ------------------------------------------ |
| `404`  | `DRAIN_NOT_FOUND` | No drain with that id in this organization |

### PATCH /admin/orgs/:orgId/log-drains/:drainId

Updates a drain. Accepts every `POST` field except `type`, which cannot be changed. Sending `null` for `sampling`, `redact`, or `scrubRules` clears it.

Changing `endpoint` moves the drain back to `pending_verification`: delivery stops until it is re-verified, and records keep buffering meanwhile.

**Response**

`200` with the updated `LogDrainRead`.

**Errors** — as for `POST`, plus `404 DRAIN_NOT_FOUND`.

<CodeGroup>
  ```bash CLI theme={null}
  lua drains update drn_2b7f10d9ac4e83615702ffab --min-severity error
  ```

  ```ts TypeScript theme={null}
  const response = await fetch(
    'https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains/<<DRAIN_ID>>',
    {
      method: 'PATCH',
      headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
      body: JSON.stringify({ minSeverity: 'error', sampling: null }),
    },
  );
  const updated: { state: string } = await response.json();
  console.log(updated.state);
  ```

  ```bash cURL theme={null}
  curl -sS -X PATCH "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains/<<DRAIN_ID>>" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "minSeverity": "error" }'
  ```
</CodeGroup>

### DELETE /admin/orgs/:orgId/log-drains/:drainId

Soft-deletes the drain, purges its stored header values and signing secret, and drops whatever it had queued. The name becomes free for reuse.

**Response**

`204`, no body.

**Errors**

| Status | Code              | Meaning                                    |
| ------ | ----------------- | ------------------------------------------ |
| `404`  | `DRAIN_NOT_FOUND` | No drain with that id in this organization |

## Operations

### POST /admin/orgs/:orgId/log-drains/:drainId/test

Enqueues one `lua.drain.test` record and sends it through the real delivery path. The outcome lands on the drain's deliveries.

**Response**

`202` with `{ "batchId", "enqueuedAt" }`.

<CodeGroup>
  ```bash CLI theme={null}
  lua drains test drn_2b7f10d9ac4e83615702ffab --json
  ```

  ```bash cURL theme={null}
  curl -sS -X POST "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains/<<DRAIN_ID>>/test" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### POST /admin/orgs/:orgId/log-drains/:drainId/verify

Runs [ownership verification](/drains/generic-https#how-ownership-verification-works). For an `http` drain a single-use token valid for 10 minutes is sent as `X-Lua-Verify` and must be echoed back; for the vendor types any `2xx` from the intake is accepted.

On success the drain moves to `healthy`, `verifiedAt` is set, and the buffered backlog starts draining.

**Response**

`202` with `{ "verificationId", "expiresAt" }`. The outcome appears on the drain's `verification` object and in its deliveries.

**Errors**

| Status | Code                        | Meaning                                        |
| ------ | --------------------------- | ---------------------------------------------- |
| `404`  | `DRAIN_NOT_FOUND`           | No drain with that id in this organization     |
| `429`  | `DRAIN_VERIFY_RATE_LIMITED` | More than 5 attempts for this drain in an hour |

### POST /admin/orgs/:orgId/log-drains/:drainId/pause

Stops delivery. Records keep buffering, within the six-hour horizon and the buffer size.

<ParamField body="reason" type="string">Recorded with the pause.</ParamField>

**Response**

`200` with the updated `LogDrainRead`, `state: "paused"` and `health.pauseReason: "manual"`.

### POST /admin/orgs/:orgId/log-drains/:drainId/resume

Resumes delivery. The drain returns to `degraded`, never straight to `healthy` — it has to prove itself again.

**Response**

`200` with the updated `LogDrainRead`.

### POST /admin/orgs/:orgId/log-drains/:drainId/rotate-secret

Mints a new HMAC signing secret and opens a 24-hour rotation window in which every delivery carries two `v1` signatures. Pass `?finalize=true` to close an open window instead.

Only a signed drain has a secret to rotate. The vendor presets — `otlp`, `datadog`, and `betterstack` — deliver unsigned and authenticate with the header you configured, so this route answers `400` on one of them rather than minting a secret that nothing would sign with.

<ParamField query="finalize" type="boolean" default="false">Closes the current rotation window early. No new secret is minted.</ParamField>

**Response**

Without `finalize`: `200` with `{ "secret", "rotation": { "startedAt", "expiresAt" } }`. **The secret is shown once.**

With `finalize=true`: `200` with the updated `LogDrainRead`.

**Errors**

| Status | Code                         | Meaning                                             |
| ------ | ---------------------------- | --------------------------------------------------- |
| `404`  | `DRAIN_NOT_FOUND`            | No drain with that id in this organization          |
| `409`  | `DRAIN_ROTATION_IN_PROGRESS` | A window is already open and `finalize` was not set |

<CodeGroup>
  ```bash CLI theme={null}
  lua drains rotate-secret drn_9f31a7c04b2e615d8a03cc71 --json
  ```

  ```bash cURL theme={null}
  curl -sS -X POST "https://api.heylua.ai/admin/orgs/<<YOUR_ORG_ID>>/log-drains/<<DRAIN_ID>>/rotate-secret" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### GET /admin/orgs/:orgId/log-drains/:drainId/deliveries

Recent delivery attempts, newest first. Kept for 7 days.

<ParamField query="limit" type="number" default="20">At most 100.</ParamField>
<ParamField query="cursor" type="string">The `nextCursor` from a previous page.</ParamField>
<ParamField query="kind" type="string">One of `batch`, `test`, `verify`, `heartbeat`, `dropped`, or `truncated`. Anything else is a `400`.</ParamField>

**Response**

`200` with `{ "deliveries": LogDrainDeliveryRead[], "nextCursor": string | null }`.

```json theme={null}
{
  "deliveries": [
    {
      "id": "ldd_6f2a91c4b70d38e50a1b7c92",
      "orgId": "<<YOUR_ORG_ID>>",
      "drainId": "drn_9f31a7c04b2e615d8a03cc71",
      "batchId": "01JBWA6R1KD3P8N0SY4V2XQZ7T",
      "kind": "batch",
      "attempt": 1,
      "recordCount": 412,
      "bytes": 290918,
      "bytesCompressed": 41204,
      "statusCode": 202,
      "latencyMs": 138,
      "ok": true,
      "scrubHits": 2,
      "startedAt": "2026-09-21T09:14:02.743Z",
      "expiresAt": "2026-09-28T09:14:02.743Z",
      "firstRecordId": "1789217102008-a1c4ppzqx",
      "lastRecordId": "1789217994301-k0mzr7tly"
    },
    {
      "batchId": "01JBWA5X8M0Q2F7C4H1J9B6N3D",
      "kind": "batch",
      "attempt": 1,
      "recordCount": 500,
      "bytes": 339148,
      "statusCode": 503,
      "latencyMs": 9014,
      "ok": false,
      "errorClass": "http_5xx",
      "responseExcerpt": "upstream unavailable",
      "scrubHits": 0,
      "startedAt": "2026-09-21T09:13:44.512Z"
    },
    {
      "batchId": "01JBWA4T2P5R9G1K8L3M7C2V6B",
      "kind": "batch",
      "attempt": 1,
      "recordCount": 500,
      "bytes": 341002,
      "statusCode": 200,
      "latencyMs": 151,
      "ok": true,
      "errorClass": "partial",
      "scrubHits": 0,
      "startedAt": "2026-09-21T09:12:58.104Z"
    }
  ],
  "nextCursor": "eyJ0IjoxNzg5MjE3OTk3NjQ0LCJpZCI6ImxkZF8wMWE5In0"
}
```

A partial success is the third row above: an [OTLP](/drains/opentelemetry) endpoint answered `200` with `partialSuccess.rejectedLogRecords`, so the batch counts as **delivered** (`ok: true`) and is marked `errorClass: "partial"`. It is never retried — the OTLP specification asks for that, and re-sending would duplicate the records the collector did accept. A `partialSuccess` that names **zero** refused records is a warning, not a rejection, and produces a plain `ok` row with no `errorClass` at all.

How many records were refused is counted on the drain rather than on the row: read `health.rejectedCount24h`, or the `rejected` figure in the hourly series, from `GET …/log-drains/:drainId`. The `rejectedRecords` field is part of the delivery schema and reserved for a per-row count, but nothing writes it in this phase — do not build a reconciliation on it.

A delivery record has no field that can hold a request body. `responseExcerpt` is at most 1 KB of the **destination's** response, scrubbed. `errorClass` is one of the [delivery error classes](/drains/delivery-guarantees#error-classes).

## Audit trail

Every mutation on this page writes one entry to the organization's audit log: `logs.drain.created`, `logs.drain.updated`, `logs.drain.deleted`, `logs.drain.paused`, `logs.drain.resumed`, `logs.drain.verified`, `logs.drain.secret_rotated`, `logs.drain.test_sent`, and `logs.drain.quota_paused` when the quota ladder pauses a drain by itself.

The entry is deliberately narrower than the drain it describes, because an audit log is read by more people than a drain configuration is:

| Recorded                             | Never recorded                          |
| ------------------------------------ | --------------------------------------- |
| Header **names**                     | Any header value, and its `last4`       |
| The **number** of scrub rules        | Any rule's pattern, flags, or id        |
| **Whether** signing is on            | The signing secret                      |
| The endpoint as `scheme://host/path` | Its query string, fragment, or userinfo |

Reducing the endpoint matters more than it looks: several vendors' intakes carry a token in the query string, so the whole query is dropped rather than inspected. `name`, `type`, `site`, `format`, `state`, `version`, and the selectors are recorded as they are.

## Reading logs

These routes read the platform's own copy of the same records. They need `logs:read`.

### GET /developer/agents/:agentId/logs

One agent's execution logs, newest first.

<ParamField query="logSource" type="string">A log source, or `all`. This is the parameter `lua logs --type` sets.</ParamField>
<ParamField query="since" type="string">Inclusive lower bound. ISO 8601, or a relative form such as `15m`, `2h`, `7d`.</ParamField>
<ParamField query="until" type="string">Inclusive upper bound. Same formats.</ParamField>
<ParamField query="environment" type="string">`production` or `sandbox`.</ParamField>
<ParamField query="cursor" type="string">The `nextCursor` from a previous page. Cannot be combined with `page`.</ParamField>
<ParamField query="limit" type="number" default="20">Clamped to 100.</ParamField>
<ParamField query="page" type="number" default="1">**Deprecated.** Use `cursor`.</ParamField>

**Response**

`200` with `{ "logs", "nextCursor", "pagination" }`. `pagination` is kept for one release. `pagination.totalCountExact` is a boolean: above 10,000 matches `totalCount` is capped at `10000` and `totalCountExact` is `false`, so page through with `cursor` rather than trusting `totalPages`. `hasNextPage` is reliable either way.

A request that passes `page` **explicitly** also carries a `Deprecation` header and a `Link: <…?cursor=…>; rel="next"` pointing at the cursor form; the implicit first page does not.

<CodeGroup>
  ```bash CLI theme={null}
  lua logs --type agent_error --since 1h --environment production --json
  ```

  ```bash cURL theme={null}
  curl -sS "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/logs?logSource=agent_error&since=1h&environment=production&limit=100" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

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

Every agent in the organization, with the same `logSource`, `since`, `until`, `environment`, `cursor`, and `limit` parameters. `GET /admin/orgs/:orgId/logs/download` exports the same selection.

These routes are rate limited to 120 requests per minute per credential.

### Scope deprecation

The log read routes moved to `logs:read`. For **two releases** the scope each route used before is still accepted, so nothing breaks while keys are re-minted:

| Route                                   | Scope       | Also accepted, deprecated |
| --------------------------------------- | ----------- | ------------------------- |
| `GET /developer/agents/:agentId/logs`   | `logs:read` | `knowledge:read`          |
| `GET /developer/:agentId/:skillId/logs` | `logs:read` | `automations:read`        |
| `GET /admin/orgs/:orgId/logs`           | `logs:read` | `analytics:read`          |
| `GET /admin/orgs/:orgId/logs/download`  | `logs:read` | `analytics:read`          |

A request authorized by a route's previous scope still succeeds and carries three response headers. `Warning` names the scope that authorized **that** request, so a per-skill route answers `automations:read` and an organization route `analytics:read`:

```text theme={null}
Deprecation: true
Sunset: Wed, 31 Mar 2027 00:00:00 GMT
Warning: 299 - "knowledge:read on log routes is deprecated; use logs:read"
```

Watch for `Deprecation: true` in your clients and re-mint those keys with `logs:read` before the date in the `Sunset` header. After it, only `logs:read` is accepted.

<Note>
  The `Sunset` value above is the one in force as this is written, and is subject to change until the release is announced. Read the date from the header your own calls receive rather than pinning this literal.
</Note>

## See also

* [Ship logs to your stack](/drains/overview) — what a drain is and what it selects
* [Event schema](/drains/event-schema) — the shape of what a drain delivers
* [Delivery guarantees](/drains/delivery-guarantees) — retry, drops, and the health states behind `state`
* [`lua drains`](/reference/cli/drains) — the same operations from a terminal
* [REST API overview](/reference/rest/overview) — base URL, authentication, and the error envelope
