Triggers
What starts an automation when no one is calling — a schedule, an inbound webhook, or a manual fire — with every firing recorded.
Overview
A trigger points at something runnable — an agent, a tool, an orchestration or an eval — and says what starts it:
schedule— a 5-field cron expression in UTC, fired by the runtime;webhook— an inbound call signed with the trigger's own secret;event— a subscription to an event the upstream runtime emits internally, matched by pattern;manual— fired through this API.
Each firing is a record of its own: what started it, what it produced, and whether it succeeded. That is the difference between a trigger and a cron entry in your own infrastructure — the attempt is auditable, and the result is linked to the thing it created.
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. It
serves two collections — /triggers and /trigger-firings.
How many. Counted across every project the billing owner pays for: Free
holds 3, Pro 30, Business 500; an Enterprise ceiling is set by contract.
Creating one past that, directly or by deploying a formation, answers
403 plan_limit_reached, with the plan and the limit in details.
How often. A schedule may not fire more often than the plan allows — hourly
on Free, every 5 minutes on Pro, every minute on Business, by contract on
Enterprise. A cron that fires more often than that answers
403 plan_limit_reached with resource: "trigger_interval" and the minimum, in
seconds, as limit. It is measured on the closest two firings the schedule
produces, so 0 9,17 * * * is read as eight hours rather than as twice a day.
Updating a trigger is checked the same way, and a webhook trigger has no
schedule to measure.
Run an agent on a schedule
shows the refusal on Free.
Both caps also apply to a formation template that declares
triggers, before it reaches the runtime — the details.field names the
declaration that crossed the line.
These are the two deviations from the verbatim mirror on this module: both
refusals are naturali's, not the runtime's. A cron naturali cannot parse is
forwarded, so an invalid expression is still the runtime's to refuse.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Triggers.
Data Model
Trigger
| Field | Type | Description |
|---|---|---|
id | string | Public trigger ID. |
project_id | string | The owning project. |
name | string | |
description | string, nullable | |
type | string | manual, webhook, schedule or event. |
target_type | string | orchestration, agent, tool or eval. |
target_id | string | The resource to run. |
action | string, nullable | Tool targets only — the action for mcp tools. |
input | object, nullable | Static input, shallow-merged under fire-time input. |
tool_context | object, nullable | Write-only tool context every firing forwards to the run it starts — see A schedule carries its own tool context. Never returned by a read. |
cron | string, nullable | 5-field cron expression (UTC); schedule triggers only. |
event_pattern | string, nullable | Event subscription pattern (*, prefix.*, or an exact event name); event triggers only — see Event triggers. |
active | boolean | An inactive trigger neither fires nor accepts a webhook. |
policy_id | string, nullable | Optional boundary policy restricting what a firing may do. |
next_fire_at | string (date-time), nullable | Read-only, schedule triggers only. |
created_at / updated_at | string (date-time) |
TriggerFiring
| Field | Type | Description |
|---|---|---|
id | string | Public firing ID. |
trigger_id | string | The trigger that fired. |
project_id | string | |
source | string | manual, webhook, schedule or event — how this firing started. |
status | string | pending, running, succeeded or failed. |
input | object, nullable | The effective input for this firing. |
result | object, nullable | { target_type, result_id, status, output } — the thing the firing produced. |
error | object, nullable | { code, message, meta } when it failed. |
started_at / completed_at | string (date-time), nullable | |
created_at / updated_at | string (date-time) |
Key Concepts
Firing is recorded, not fire-and-forget
POST /v1/projects/{project_id}/triggers/{trigger_id}/fire
creates a firing and returns it. Read one back with
GET /v1/projects/{project_id}/trigger-firings/{firing_id},
or list one trigger's log with
GET /v1/projects/{project_id}/trigger-firings —
trigger_id is required there, so the log is always read per trigger rather than
project-wide.
result.result_id is the handle to what it produced — a
generation, an orchestration run, or an
eval run — so a scheduled job is traceable to its output
rather than to a log line.
Run an agent on a schedule
fires a new schedule by hand, follows result_id
to its generation
and reads the log.
A negative balance stops a firing by hand
Whatever the trigger targets goes on to generate, so
POST /v1/projects/{project_id}/triggers/{trigger_id}/fire
answers 402 insufficient_credit and records no firing while the project owes
for usage already served. So does a signed
webhook call. It is the project
owner's balance. See
A negative balance stops managed generation.
A Free account that has used its plan's monthly runs answers
403 plan_limit_reached with resource: "runs" here as well, on its own
credential too. See
A plan's run allowance can stop generation.
A trigger's scheduled firings are not refused this way: they never pass through this API, so nothing here sees them. Reading the log is never refused either.
A debt turns a schedule off
Since a scheduled firing is not refused, it is stopped instead. When the
reconciliation finds the project owner's balance negative, every schedule
trigger they hold is set active: false — across all their projects, on the
same balance. A manual, webhook or event trigger is untouched: those fire
because something already refused above asked them to.
Turning one back on
is yours to do after a top-up; nothing re-enables a schedule
for you. Everything a debt stopped is listed at
GET /v1/users/me/stops, to restore
or dismiss one by one — see
Restoring what billing stopped.
Only a negative balance does this. A zero balance keeps its schedules, which is
the same line 402 insufficient_credit
is drawn on. The stop lands within one reconciliation interval of the debt
appearing, so a schedule may fire a few more times before it stops.
Static input sits under fire-time input
A trigger's input is a default: whatever the firing supplies is shallow-merged
on top. So a schedule can carry the constant part of a payload while a manual or
webhook fire varies the rest.
A schedule carries its own tool context
A tool that authorizes per call reads its credential from the
tool_context of
whoever started the work. A scheduled firing has no such caller, so a trigger
carries the bag itself: tool_context on the trigger is forwarded to every run
a firing starts, and each key reaches the endpoint as one
X-Naturali-Context-<key> header.
It is write-only — accepted on create and update, never returned on a read, so the record cannot be used to recover a value.
A value may be a {{secret:...}} reference rather than a literal, which is what
a stored bag should carry: the credential stays in the
secret store and only its name sits on the trigger, so rotating
the secret changes the next firing without touching the trigger. The reference
is checked against the project when it is written — one naming a secret that
does not exist is refused there, not at fire time.
A manual fire has a caller, so it may pass
tool_context of its own, shallow-merged per key over the stored bag. That half
is forwarded exactly as written: a {{secret:...}} is resolved only in the
trigger's stored bag, never in one supplied at fire time.
The webhook secret is shown once
GET /v1/projects/{project_id}/triggers/{trigger_id}/secret
reads the current signing secret, and
POST /v1/projects/{project_id}/triggers/{trigger_id}/rotate-secret
replaces it. Rotating invalidates the old one immediately, so update the caller
before you rotate.
A webhook trigger is called at its own URL
A webhook trigger fires when the system you connect calls
POST https://api.naturali.ai/v1/projects/{project_id}/hooks/triggers/{trigger_id}
It takes no bearer token: the caller signs the raw body with the trigger's
secret and sends
X-Naturali-Signature: sha256=<hex HMAC-SHA256 of the body>. The body, at most
1 MiB of JSON, is the fire-time input; a value that is not an object arrives as
{ "payload": … }. The SDK and the CLI have no method for it, since the caller
is not a client of this API.
import { createHmac } from 'node:crypto';
const body = JSON.stringify({ order_id: 'ord_123' });
const digest = createHmac('sha256', process.env.TRIGGER_SECRET!)
.update(body)
.digest('hex');
await fetch(
'https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/hooks/triggers/trg_V1StGXR8Z5jdHi6B',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Naturali-Signature': `sha256=${digest}`,
},
body,
}
);
| Status | When |
|---|---|
202 | Accepted: { firing_id, trigger_id, status }. The firing runs in the background; read it back by firing_id. |
404 | No such project, or no webhook trigger by that id in it. |
401 | The signature is missing or not made with the current secret. |
402 / 403 | Signed, but the owner's balance or run allowance refuses it. |
409 | The trigger is inactive. |
400 | The body is not JSON, or the input does not fit the target. |
413 | The body is over 1 MiB. |
Event triggers
An event trigger subscribes directly to the upstream runtime's internal event
bus instead of receiving a signed webhook call — the same event, delivered
in-process rather than as a network round trip back into the API. Firing works
the same as any other trigger: a TriggerFiring is created, and its input is
the event's payload.
event_pattern uses the same grammar as a webhook subscription's events
entries: documents.ingested matches that event only, documents.* matches
every event in that namespace, and * matches everything the project emits.
Delivery is best-effort and unordered — the runtime's event bus makes no
stronger guarantee, so an event trigger is for reactive automation whose value
is promptness, not for work that must not be lost. Keep a schedule trigger
over the same condition as a backstop when it must not be lost; the two
compose, and a target that is idempotent per resource makes the overlap
harmless.
Because a reactive edge can feed itself, two guards apply that do not apply to any other trigger type:
- A causal loop is refused, not run. Every event carries the chain of
trigger firings that produced it. A trigger refuses to extend a chain that
already names it, or one that has already run several hops deep — either
refusal records a
failedfiring and files anevent_trigger_loopexception so the loop is triaged rather than merely stopped. - Quota admission runs before dispatch. An event trigger never passes through the same request path an API call does, so its firing is checked against the project's quotas before it runs — a wide pattern on an expensive target is still bounded.
Scheduling an eval is the point of eval targets
A schedule trigger against an eval is how a suite runs
nightly instead of when someone remembers: the eval run it produces carries
trigger_id, so a scored regression is attributable to the schedule that found
it.
Who may do what
Every route needs any project member, except the webhook call, which needs the trigger's signature instead.
Examples
Schedule a nightly eval
- CLI
- SDK
- curl
naturali create-trigger \
--project-id proj_V1StGXR8Z5jdHi6B \
--name nightly-billing-evals \
--type schedule \
--cron "0 3 * * *" \
--target-type eval \
--target-id eval_V1StGXR8Z5jdHi6B
const { data: trigger } = await naturali.triggers.createTrigger({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'nightly-billing-evals',
type: 'schedule',
cron: '0 3 * * *',
target_type: 'eval',
target_id: 'eval_V1StGXR8Z5jdHi6B',
},
});
console.log(trigger?.next_fire_at);
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/triggers \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "nightly-billing-evals",
"type": "schedule",
"cron": "0 3 * * *",
"target_type": "eval",
"target_id": "eval_V1StGXR8Z5jdHi6B"
}'
Fire one by hand with extra input
- CLI
- SDK
- curl
naturali fire-trigger \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B \
--input '{ "agent_version": 4 }'
const { data: firing } = await naturali.triggers.fireTrigger({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
trigger_id: 'trg_V1StGXR8Z5jdHi6B',
},
body: { input: { agent_version: 4 } },
});
console.log(firing?.status, firing?.result?.result_id);
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/triggers/trg_V1StGXR8Z5jdHi6B/fire \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "input": { "agent_version": 4 } }'
Audit what one trigger has done
- CLI
- SDK
- curl
naturali list-trigger-firings \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B \
--limit 20
const { data: firings } = await naturali.triggers.listTriggerFirings({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { trigger_id: 'trg_V1StGXR8Z5jdHi6B', limit: 20 },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/trigger-firings?trigger_id=trg_V1StGXR8Z5jdHi6B&limit=20" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Rotate the webhook secret
- CLI
- SDK
- curl
naturali rotate-trigger-secret \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B
const { data: rotated } = await naturali.triggers.rotateTriggerSecret({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
trigger_id: 'trg_V1StGXR8Z5jdHi6B',
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/triggers/trg_V1StGXR8Z5jdHi6B/rotate-secret \
-H "Authorization: Bearer $NATURALI_TOKEN"