Exceptions
What went wrong, how often, and what was done about it.
Overview
An exception is the record a failure leaves behind: a run that failed, an
approval that expired unanswered, a metering gap, or something
filed by hand. It carries a one-line title, structured detail, a severity,
and the correlation ids that lead back to the run.
Two things make it a worklist rather than a log. Repeat occurrences of the same
failure increment occurrence_count and move last_seen_at instead of piling up
new rows — so "failing constantly" and "failed once" look different at a glance.
And the status moves open → acknowledged → resolved, recording who took it
and who closed it.
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
Exceptions are sold with guardrails and are part of the Pro plan and above. On a project whose billing
owner is on a lower rung, listing them answers
403 plan_feature_not_included with the plan and the feature in details,
(no formation resource type declares an exception — the runtime writes them). The plan is the project owner's, not the caller's.
An exception that already exists stays closeable on every rung.
GET,
POST …/acknowledge and
POST …/resolve answer whatever the
plan, so a project that drops below the rung is not left holding an open
exception it cannot close. Its id reaches such a project through the
activity feed, which is on every rung — kind=exception_created
carries it in ref_id. Closing one out still needs membership of the project;
only the plan gate is lifted.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Exceptions.
Data Model
ExceptionItem
| Field | Type | Description |
|---|---|---|
id | string | Public item ID (exc_ prefix). |
project_id | string | The owning project. |
status | string | open, acknowledged or resolved. |
severity | string | info, warning or critical. |
kind | string | How it was filed — see What files an exception. |
title | string | Human-readable one-line summary. |
detail | object, nullable | Structured context: the tool, an arguments digest, the error message, the guardrail version. |
occurrence_count | integer | How many times this exact failure has been seen while open. |
last_seen_at | string (date-time) | The most recent occurrence. |
orchestration_run_id | string, nullable | Originating orchestration run. |
node_id | string, nullable | Originating node in that run's graph. |
agent_id | string, nullable | The associated agent. |
guardrail_version | string, nullable | <guardrailId>@<version> on a tripwire — see the note below. |
acknowledged_by | string, nullable | The acknowledging user's public ID. |
resolved_by | string, nullable | The resolving user's public ID. |
resolution_note | string, nullable | Optional note recorded at resolution. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
What files an exception
kind says which producer filed it, and it is the first thing to filter on
because the kinds mean very different things:
kind | Filed when |
|---|---|
run_failed | A run ended in failure. |
guardrail_tripwire | A guardrail's tripwire condition fired. |
approval_expired | An approval expired with nobody deciding. |
quota_unpriced | Usage could not be priced, so it could not be metered against a limit. |
chain_limit | An agent's max_chain_generations stop condition reached the continuation chain's generation budget. |
event_trigger_loop | An event trigger refused to extend a causal chain that already named it, or that had run past the depth cap. |
manual | Someone filed it. |
approval_expired is worth a standing filter of its own: it means a gate stopped
work and no human answered, which is a process failure rather than a technical
one.
Occurrence counting
While an item is open, an identical failure updates it — occurrence_count
climbs and last_seen_at moves — instead of creating another row. So the list
stays the length of your distinct problems, and the count is the signal for
which to take first.
Resolving an item closes that window. A failure that happens again afterwards is a new item, which is what tells you a fix did not hold.
Acknowledge, then resolve
acknowledged means "someone is on it" and resolved means "fixed"; both record
the user. The two calls are deliberately not interchangeable:
- Acknowledging something already acknowledged is a no-op that returns the item unchanged — two people claiming the same item is not an error.
- Acknowledging something already resolved is a
409. Reopening is not what that call does.
Who may do what
Every route needs any project member.
kind: "guardrail_tripwire" items carry guardrail_version as
<guardrailId>@<version> — the exact document that stopped the call. Split it
and fetch that archived version with
GET /v1/projects/{project_id}/guardrails/{guardrail_id}/versions/{version}
to see the policy as it stood, rather than as it stands now.
Examples
Read the open worklist, worst first
- CLI
- SDK
- curl
naturali list-exceptions \
--project-id proj_V1StGXR8Z5jdHi6B \
--status open \
--severity critical
const { data: open } = await naturali.exceptions.listExceptions({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { status: 'open', severity: 'critical' },
});
for (const item of open?.data ?? []) {
console.log(item.occurrence_count, item.title, item.last_seen_at);
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/exceptions?status=open&severity=critical" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Find gates nobody answered
- CLI
- SDK
- curl
naturali list-exceptions \
--project-id proj_V1StGXR8Z5jdHi6B \
--kind approval_expired
const { data: stalled } = await naturali.exceptions.listExceptions({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { kind: 'approval_expired' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/exceptions?kind=approval_expired" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Read one in full
- CLI
- SDK
- curl
naturali get-exception \
--project-id proj_V1StGXR8Z5jdHi6B \
--exception-id exc_V1StGXR8Z5jdHi6B
const { data: item } = await naturali.exceptions.getException({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
exception_id: 'exc_V1StGXR8Z5jdHi6B',
},
});
console.log(item?.detail, item?.orchestration_run_id, item?.node_id);
curl https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/exceptions/exc_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN"
Take it, then close it
- CLI
- SDK
- curl
naturali acknowledge-exception \
--project-id proj_V1StGXR8Z5jdHi6B \
--exception-id exc_V1StGXR8Z5jdHi6B
naturali resolve-exception \
--project-id proj_V1StGXR8Z5jdHi6B \
--exception-id exc_V1StGXR8Z5jdHi6B \
--note "Upstream API was returning 500s; retry added."
await naturali.exceptions.acknowledgeException({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
exception_id: 'exc_V1StGXR8Z5jdHi6B',
},
});
const { data: item } = await naturali.exceptions.resolveException({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
exception_id: 'exc_V1StGXR8Z5jdHi6B',
},
body: { note: 'Upstream API was returning 500s; retry added.' },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/exceptions/exc_V1StGXR8Z5jdHi6B/acknowledge \
-H "Authorization: Bearer $NATURALI_TOKEN"
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/exceptions/exc_V1StGXR8Z5jdHi6B/resolve \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "note": "Upstream API was returning 500s; retry added." }'