Skip to main content
These routes configure log drains — 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; lua drains is the same surface from a terminal.
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.

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. 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. The log export routes live on this same controller and share this error envelope, but take logs:read rather than logs:manage. The usage report is on the usage dashboard and takes usage:read.

Destination types

type accepts ten values, but a deployment opens them individually. A create naming one that is not open answers 422 DRAIN_TYPE_UNAVAILABLE, and the message names the live list:
A shorter list is a rollout state, not a bug. Read the message rather than hard-coding a set — the list is evaluated per request, so a type can open without any change on your side. type cannot be changed after creation: PATCH refuses it. Delete the drain and create a new one. A test post proves the host answers and the credential works. Whether it also proves the destination is the vendor’s own intake depends on whether you could name the host at all — which is what verification.ownership records, and what Protecting your destination reasons through.

Sources

Six sources values are available from lua-cli 3.39.0: delivery, trigger-execution, job-execution, workflow-run, voice-call, and workflow-audit. On a deployment that predates them, a create or patch naming one answers 400 DRAIN_VALIDATION_FAILED with field: "sources".
Twenty-five sources are selectable from that release. The default set for a new drain gains delivery, trigger-execution, job-execution, and workflow-run; voice-call and workflow-audit are selectable but not defaulted, and must be named in sources. POST spreads the default set only when the body names no sources at all. PATCH writes selectors.sources only when the body names it — and an empty array is refused — so a drain that already exists is never re-derived from the defaults, and a source added to the default set is never granted to a drain a customer already configured. The one exception is execution, which was already a default: a drain that selects it receives four new lua.execution.* outcome names with no opt-in. That is additive under the schema policy. includeContent is unrelated to the voice-call and workflow-audit opt-in. includeContentAck is required only for includeContent itself, and the content sources remain user_message and agent_response alone — a drain selecting only the new sources never needs an acknowledgement. includeContent still governs a handful of the new attributes (the full delivery recipient, trigger payload and response, a workflow event’s data, a recording URL, and the audit evidence group), which are withheld without it while the record itself still arrives.

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: 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: 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.

The per-drain budgets

Two routes send bytes to a destination on demand, so each carries a second, narrower budget on top of the organization bucket — counted per drain, over a rolling hour: Both refusals are a 429 carrying Retry-After in seconds and the same number as retryAfterS in the body, so a client can back off on either without parsing a header it may not be able to see:
The three 429s are three different codes, and they are worth telling apart. DRAIN_RATE_LIMITED is the organization bucket above — every caller in your organization shares it, and it refills within the minute. DRAIN_TEST_RATE_LIMITED and DRAIN_VERIFY_RATE_LIMITED are one drain’s own budget over an hour, and no other drain is affected. A client that maps every 429 onto “slow down and retry in a second” will spin for the better part of an hour on the last two. Read retryAfterS.
The budgets are counted from what was actually sent rather than held in memory, so they are the same numbers on every pod, a restart does not refill them, and retryAfterS is the real wait until the oldest attempt falls out of the window. The log read routes 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. A drain whose credential is embedded in its endpoint — Sumo Logic — carries endpointSecretLast4 beside secretLast4, and its endpoint is the prefix with the credential split off. An s3 drain carries its bucket block, with credentials.secretLast4 and no secretAccessKey, and a challenge whose tokenHash reads as an empty string.

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

Creates a drain. It starts in pending_verification and buffers rather than delivers until ownership is proved.
string
required
1–64 characters, unique in the organization.
string
required
http, otlp, datadog, betterstack, splunk, loki, axiom, newrelic, sumologic, or s3. Availability is per deployment — see Destination types.
string
The destination URL. Required for http, otlp, betterstack, splunk, loki, axiom, and sumologic; refused for datadog and newrelic, whose URLs are derived from site, and for s3, which derives its own from bucket. HTTPS only, and never a private, loopback, link-local, unspecified, or cloud-metadata address.
string
datadog: one of datadoghq.com, datadoghq.eu, us3.datadoghq.com, us5.datadoghq.com, ap1.datadoghq.com, ddog-gov.com. newrelic: one of log-api.newrelic.com, log-api.eu.newrelic.com, log-api.jp.newrelic.com.
object
Required for s3, refused for every other type. See the bucket block.
string
default:"json"
json or ndjson. Honoured for http; the other types fix their own encoding, and s3 is always ndjson.
object[]
Up to 10 { name, value } pairs. Write-only: values are stored encrypted and never returned. name matches ^[A-Za-z0-9-]{1,64}$.
string
Write-only HMAC signing secret for an http drain. Generated when omitted, and returned exactly once in this response.
string[]
The sources to copy. Defaults to every source except the four that are off on a new drain: user_message, agent_response, voice-call, and workflow-audit.
string
default:"info"
debug, info, warn, or error.
string | string[]
default:"*"
"*" for every agent in the organization, now and later, or a list of agent ids that must belong to this organization.
string[]
default:"[\"production\"]"
Any of production, sandbox.
object
{ "debug": 0.1 }. Deterministic head sampling by execution, 0 < n <= 1. Only debug is sampleable.
boolean
default:"false"
Turns on the user_message and agent_response bodies.
boolean
Required true on the request that turns includeContent on — a POST creating a content-carrying drain, or a PATCH moving it from off to on. A PATCH that leaves the flag on as it was, or turns it off, needs no acknowledgement; turning it back on asks again. The acknowledgement is recorded on the drain as contentAck and is never returned as a bare boolean.
string[]
Up to 32 attribute keys to drop before encoding, for example ["user.id"].
object[]
Up to 20 organization masking rules, { id, pattern, flags? }. Each pattern is at most 256 characters and is compiled with RE2, so there are no backreferences, no lookahead, no lookbehind, no nested quantifiers, and no unbounded repetition of an alternation.
The body is closed. A key this page does not list is a 400 DRAIN_VALIDATION_FAILED whose field and message name it, and no drain is created. A mis-nested field — selectors.redact instead of the top-level redact — fails the same way rather than being dropped.

The bucket block

Required for type: "s3" and refused for every other type. See Object storage for the key layout, the policy, and the ownership challenge.
string
required
aws, r2, or gcs.
string
required
3–63 characters, lower-case, no dots — a dotted name in a virtual-hosted URL breaks TLS verification.
string
required
1–32 characters. The vendor’s region code; auto for R2.
string
Up to 256 characters. No leading or trailing /.
string
The S3-compatible endpoint. Required for r2 and gcs; refused for aws, which derives its own from the bucket and the region. The host must match that provider’s own shape, checked before any DNS lookup.
object
Exactly one of the two shapes: a key pair, or a role to assume.
string
The SigV4 access key id — an identifier, not a secret, and returned on a read.
string
8–512 characters. Write-only: stored encrypted and returned by nothing. A read carries bucket.credentials.secretLast4 instead.
string
AWS only: assume this role instead of holding a key pair. Mutually exclusive with the key pair — sending both answers 422 DRAIN_VALIDATION_FAILED with field: "bucket.credentials".
string
The sts:ExternalId your role’s trust policy requires.
On PATCH, the whole bucket block is replaced, never merged. A half-patched destination — a new bucket with the old credentials — is a drain writing somewhere you did not ask for. Omit bucket to keep the current one. 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
A PATCH that carries scrubRules is validated in full, even when you did not mean to change them. Lookahead — (?=…), (?!…) — was accepted by an earlier rule set and is refused now, so a read-modify-write that sends a drain’s existing rules straight back answers 422 DRAIN_SCRUB_RULE_INVALID, with the offending rule named in message, until the pattern is rewritten. Omit scrubRules from the body to leave the stored rules alone, or rewrite the pattern — which you want to do anyway, because a rule that no longer compiles does not run.

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

Reads one drain, with health, quota, and the last verification outcome. Response 200 with a LogDrainRead:
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.

verification.ownership

verifiedAt says when a drain last verified. verification.ownership says what that verification established, and it is the field to read before describing a drain as proven: verified on a pinned preset means the bytes reached that vendor, not that the account at that vendor is yours. Protecting your destination has the reasoning and what to audit instead. The key is absent on a drain verified before it existed, and an absent ownership reads as reachability-only — the weaker claim, which is the only safe reading of a check that ran before the distinction did. It fills in on the drain’s next successful verification. Treat ownership !== "verified" as the test, not ownership === "reachability-only".

The content acknowledgement

A drain with selectors.includeContent: true carries contentAck: who agreed to send end-user message content to this destination, and when.
The block is absent on a drain that carries no content, and absent on a content-carrying drain created before the record existed. It is never a bare true: “somebody agreed” is the answer this field exists to improve on. Errors

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. The body is closed. A key this page does not list is a 400 DRAIN_VALIDATION_FAILED whose message names it, so a typo or a mis-nested field fails instead of being dropped. In particular redact is a top-level field — {"redact": ["app.error_message"]} — not selectors.redact. A PATCH that changes nothing is not a write. If every key you send already holds that value, the drain is returned with the same version and updatedAt, and no audit entry is recorded. version moves by one only when the stored document actually changed. 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.

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

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" }. Errors

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

Runs ownership verification. 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

POST /admin/orgs/:orgId/log-drains/:drainId/challenge/confirm

Settles an object-storage drain’s ownership challenge, and applies to type: "s3" alone. POST …/verify writes {prefix}/_lua-verify-<token> into the bucket. If the drain’s credentials can read it back, verification finishes on its own and this route is never needed. With the write-only grant we recommend they cannot, so the caller lists their own prefix, opens the object, and echoes the token here. Finding it in the bucket is the proof: Lua stores only a hash and returns the token from no route, because a control plane that could hand it back would let anyone holding logs:manage confirm a bucket they cannot even read.
string
required
8–256 characters. The token from the _lua-verify-<token> object in your bucket.
Response 200 with { "verifiedAt": "2026-09-22T14:11:07.244Z" }. The drain moves to healthy and its buffered backlog starts draining. Idempotent: confirming an already-settled challenge answers 200 with the existing verifiedAt rather than an error. Errors A challenge expires 10 minutes after verify mints it. Run verify again for a new one. The token is compared in constant time, and the three refusals are deliberately indistinguishable in everything but their message.

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

Stops delivery. Records keep buffering, within the six-hour horizon and the buffer size.
string
Recorded with the pause.
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, and http is the only signed type. Every other destination — otlp, s3, and the seven vendor presets — delivers unsigned and authenticates with the credential you configured, so this route answers 400 on one of them rather than minting a secret that nothing would sign with.
boolean
default:"false"
Closes the current rotation window early. No new secret is minted.
Response Without finalize: 200 with { "secret", "rotation": { "startedAt", "expiresAt" } }. The secret is shown once. With finalize=true: 200 with the updated LogDrainRead. Errors

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

Recent delivery attempts, newest first. Kept for 7 days.
number
default:"20"
At most 100.
string
The nextCursor from a previous page.
string
One of batch, test, verify, heartbeat, dropped, or truncated. Anything else is a 400.
Response 200 with { "deliveries": LogDrainDeliveryRead[], "nextCursor": string | null }.
A partial success is the third row above: an OTLP 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. Errors
An unknown drainId on this route used to answer an empty page — and an empty page is exactly what a healthy drain with nothing to show answers. The only conclusion available to the reader, “this drain has delivered nothing”, was false, and it is the conclusion an on-call engineer acts on.Once the change is enabled on your deployment, an id that names no live drain of this organization answers 404 DRAIN_NOT_FOUND instead, byte-identical to what GET …/log-drains/:drainId has always answered. Until it is, the historical { "deliveries": [], "nextCursor": null } still comes back — so do not read an empty page as proof of anything until you have checked the id. From a terminal you can side-step it entirely and pass the drain’s exact name, which the CLI resolves to an id before the route is asked.
The 404 is not an existence oracle. An id that never existed, an id belonging to another organization, a soft-deleted drain, and a name typed where an id goes all produce the same response, so a drain id still cannot be probed across tenants.

rejectedRecords

rejectedRecords is how many records the destination refused out of the recordCount this attempt carried. A count, never the records — the refused records are yours to chase at your own destination. Three rules decide whether the key is there at all:
  • It is present only when it is greater than zero. A partial_success that names zero refused records is a warning, not a rejection, and produces a plain ok row with no errorClass and no rejectedRecords.
  • It is written on a partial accept, and only there — a 2xx (ok: true) that still refused N records, carrying errorClass: "partial" beside the count, never retried. That is the OTLP partialSuccess case and, once enabled on your deployment, a generic HTTPS receiver’s rejected reply — which also fills in rejectedIds below.
  • A terminal refusal deliberately does not set it. That row already says ok: false with its own errorClass, and its recordCount is the whole batch; writing the batch size here too would make one column mean two things. The drain’s own counters still count a terminal batch under rejectedCount24h — that side is unchanged.
So rejectedCount24h on GET …/log-drains/:drainId, and the rejected figure in the hourly series, remain the figures to total for a period. rejectedRecords is what attributes a partial accept to the attempt that caused it.
A row with errorClass: "partial" and no rejectedRecords is ambiguous on older rows. Rows written before this field started being populated carry the class without the count — that is age, not a bug, and it resolves itself: delivery rows are kept for 7 days, so no such row survives beyond that.
The same numbers reach the usage report, where billableRecords is deliveredRecords − rejectedRecords per batch.

rejectedIds

Which records the destination refused, when it named them. A generic HTTPS receiver can answer a 2xx with {"rejected":[{"id","reason"}]}, and that list — intersected with the batch actually sent, so an id the receiver invented never reaches the row — becomes rejectedIds:
reason is the receiver’s own text, scrubbed and capped at 200 characters, and is absent when it sent none. At most 50 entries are kept — rejectedRecords stays the authoritative count, and this is the evidence. The key is present only beside errorClass: "partial", and never on a plain 2xx. An OTLP partial_success reports a count and no ids, so an OTLP row carries rejectedRecords alone.

replayOf

The batch id this attempt is a replay of. Present only when the whole batch is one replay’s records, and it is how you find the new attempt that carried them:
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.

POST /admin/orgs/:orgId/log-drains/:drainId/deliveries/:batchId/replay

Puts one batch’s records back in the queue after a terminal refusal. They are delivered again in a new batch with a new id, and the attempt that carries them is marked replayOf: "<batchId>". A 4xx that is not 408 or 429 is terminal — the batch is settled as dropped and never re-sent — so when the cause has been fixed this is the way to close the gap. Needs logs:manage. Response 202 with { "replayOf", "records", "enqueuedAt" }. records is how many rows went back to pending. Delivery is asynchronous; the response says the records are queued, not that they arrived.
The window is six hours, not the seven days a delivery row survives for. A record is dropped on the delivery horizon, measured on the record’s own timestamp — so re-queuing anything older would lose it a second time, quietly. It is refused instead. The records themselves are kept a little longer than that, 25 hours past the moment they settled, but a row that outlives the horizon is evidence rather than something you can re-send.The whole batch is re-sent, including records your destination already accepted. That is safe because consumers dedup on records[].id and a replay does not change one. Only kind: "batch" rows are replayable: a test, verify, heartbeat, dropped, or truncated row is the drain talking about itself, and each has its own route.
Errors Every replay writes a logs.drain.replayed entry to the organization’s audit log, naming the batch and the record count.

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, logs.drain.replayed, 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: 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.
string
A log source, or all. This is the parameter lua logs --type sets.
string
Inclusive lower bound. ISO 8601, or a relative form such as 15m, 2h, 7d.
string
Inclusive upper bound. Same formats.
string
production or sandbox.
string
The nextCursor from a previous page. Cannot be combined with page.
number
default:"20"
Clamped to 100.
number
default:"1"
Deprecated. Use cursor.
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.

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: 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:
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.
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.

See also