Projects
The isolation and billing boundary every resource belongs to.
Overview
An account holds multiple projects — one per client, or per environment. Every resource in every other module belongs to exactly one project. API keys (API Keys) are project-scoped by default, so a key from one project can never read another project's resources. A project also exposes its own usage meter, the re-billing view for tokens and cost.
Free lets you own 3 projects; Pro, Business and Enterprise do not cap them,
because what a plan limits is what the projects hold — runs, channels, triggers
and storage, all counted across your account. Creating a fourth on Free answers
403 plan_limit_reached, with the plan and the limit in details.
An archived project still counts: archiving is reversible and keeps every resource, so it frees nothing. Deleting one does.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Projects.
Data Model
Project
| Field | Type | Description |
|---|---|---|
id | string | Public project ID (proj_ prefix). |
name | string | Human-readable label. |
status | string | Lifecycle status. |
role | string | The caller's role in this project: owner, admin or member — see Membership and the billing owner. |
owner_user_id | string | The user who pays for the project. |
trace_content_retention_days | integer, nullable | Days of trace and generation content retention before a daily sweep purges it. Set from your plan when the project is created, and bounded by it — see Content retention. null disables the sweep, keeping content indefinitely. |
trace_content_mode | string | full (the default) stores trace and generation content; none is zero-retention — see Content retention. |
max_concurrent_runs | integer, nullable | Orchestration runs driven at once. null (the default) is unlimited — see Execution ceilings. |
max_chain_generations | integer, nullable | Generations one continuation chain may hold before it stops being resumed. null (the default) leaves the platform-wide ceiling — see Execution ceilings. |
max_orchestration_run_depth | integer, nullable | Nesting levels a run tree may reach before the next child is refused. null (the default) leaves the platform-wide bound — see Execution ceilings. |
require_priced_model | boolean | Refuses a generation whose model carries no price, before the provider is called. false by default — see Requiring a priced model. |
guardrail_ids | string[] | Guardrails attached at the project scope — the floor under every tool call by every agent in the project. Empty by default — see Project-scope guardrails. |
paused_at | string (date-time), nullable | When the project was paused; null while it runs — see Pausing a project. |
pause_reason | string, nullable | The reason the pause named, up to 256 characters; null while the project runs or when the pause named none. |
managed_conversion | boolean | Whether scanned PDFs, images and audio are converted on ingest with no setup. true by default — see Managed conversion. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
Membership and the billing owner
Two different questions, kept in two different places.
Membership is access. Every project-scoped request resolves through the project's member list, and the answer decides the status code:
| Situation | Answer |
|---|---|
| The caller is not a member | 404 — so one user's projects cannot be probed out of another's |
| The caller is a member, but the role is too narrow | 403, naming the role the action needs |
| The credential is scoped to a different project | 403 |
Roles are ordered. member reads and writes the project's own resources —
agents, conversations, documents, triggers, channels, webhooks, project API
keys. admin may also say who else is in the project and how the project itself
is configured. owner may also delete it, and pays for it. A Project response
carries the caller's role, so a client can hide an action the request would
refuse. The same boundary applies to every module underneath: reaching the
project at all is what a resource call asks.
owner_user_id is who pays. Exactly one billing owner per project, and the
entity a subscription attaches to. It is a separate field from the member list on
purpose: a project can be shared without the payer becoming ambiguous. Creating a
project makes you both, as
Create a provider shows.
GET /v1/projects/{project_id}/members
reads the list;
POST /v1/projects/{project_id}/members,
PATCH /v1/projects/{project_id}/members/{member_id}
and
DELETE /v1/projects/{project_id}/members/{member_id}
change it. See Adding people to a project.
Retrying a create is safe with an idempotency key
A timeout on a create is ambiguous — the resource may or may not exist — and
a duplicate costs you one of the projects your plan allows. Passing idempotency_key in the body settles it: the first
request under a key does the work and answers 201, and any later request
carrying the same key answers 200 with that same project, so an ambiguous
failure can simply be retried.
The key is claimed within your account and never silently expires, so a retry
days later still returns the original rather than creating a second. Reusing a
key with a different body is 409 idempotency_key_reused — a key names one
request. A retry arriving while the original is still running is
409 idempotency_request_in_progress; retry once it has answered.
Derive the key from the thing you are creating rather than from the attempt — a key that changes per retry deduplicates nothing.
Adding people to a project
Members are free and unlimited on every plan. naturali is priced on runs, not seats, and every run a colleague starts is already counted and billed through the project's billing owner. Adding one buys no allowance and needs none: nothing counts members and no plan limits them.
A member can spend, though — runs, storage and channels all land on the owner's plan and balance. Bring in the people you would hand the project to, and use alerting on spend if you want to be told when it moves.
Who may add whom, and who may take them out again:
| Action | owner | admin | member |
|---|---|---|---|
| Read and write the project's resources | yes | yes | yes |
| Mint a project API key | yes | yes | yes |
| Rename or reconfigure the project | yes | yes | no |
Add a member | yes | yes | no |
Add an admin | yes | no | no |
| Change a role | yes | no | no |
Remove a member | yes | yes | only themselves |
Remove an admin | yes | no | only themselves |
Remove the owner | no — transfer the project instead | no | no |
An admin cannot grant admin. That is what keeps the two roles distinct: an
admin who could mint another one could erase the difference for themselves.
Anyone may remove themselves, whatever their role — leaving takes nothing
from anybody else.
role: owner is refused on both writes. The owner is the billing owner, so
granting or moving it changes who pays; transferring a project is a different act.
Invite a colleague
promotes a member to admin and removes one.
The person you invite does not need an account
Add an address that has never signed in and naturali creates the account for it,
unverified, and records the membership immediately. The member
appears in the list right away
with status: "pending".
They become active the first time they sign in. Signing in is the ordinary
emailed-code flow, which they start themselves from the link in the invitation —
so there is no separate invitation to accept and none to expire, and the
message is not a credential: forwarding it grants nothing. Receiving the code at
the address is what proves the address.
Because the address is the whole credential, a mistyped address grants access
to whoever holds it the first time they sign in — true of every emailed
invitation. status: "pending" is what makes the mistake visible before anyone
acts on it, and the remedy is to remove the member.
The invitation email is a courtesy rather than the grant. If it cannot be sent
the membership still stands and the response still says pending, so the
colleague can be told by any other means and sign in normally.
Invite a colleague follows one
from pending to active.
- CLI
- SDK
- curl
naturali add-project-member \
--project-id proj_V1StGXR8Z5jdHi6B \
--email ana@acme.com \
--role member
const { data: member } = await naturali.projects.addProjectMember({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { email: 'ana@acme.com', role: 'member' },
});
// "pending" until Ana signs in for the first time.
console.log(member.status);
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/members \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"ana@acme.com","role":"member"}'
Adding a member needs an account-wide credential. A project-scoped key cannot do it: the member row would outlive the key that wrote it, so a confined credential would be granting access past its own revocation. One account may send a bounded number of invitations a day, which is an anti-abuse limit on the send path rather than anything a plan buys.
Project usage
GET /v1/projects/{project_id}/usage
returns token/cost usage grouped across dimensions, backing the re-billing
view a customer embeds for their own end users.
groups is a page — data, total, limit, offset — because the rollup
grows with the window: day buckets once per day and model once per model
served, so a month on a busy project is not a response anyone wants whole.
total is the count of distinct buckets in the window, independent of limit.
Cap a project's spend
reads group_by=day to size a quota.
total counts buckets, not the things they describe. Every dimension keeps a
null bucket for the events it does not apply to, so a project that runs no
orchestration has exactly one bucket under group_by=run whatever its volume.
To count the entities themselves, pass include=distinct — see
counting what a window touched. For the
account-wide run figure your plan's allowance is measured against, read
GET /v1/users/me/usage.
The top-level totals and groups.total always describe the whole window, never
the page above them — a page-scoped total read against an allowance would
understate spend by whatever was not paged through. limit tops out at 100 and
a larger value is refused rather than clamped, so a caller paging by their
own stride never silently skips a bucket.
Token counts describe LLM calls. They are not the only thing metered, so every
bucket — and the top-level total — also carries components, the amounts
actually measured:
{
"key": "storage",
"cost_usd": null,
"input_tokens": 0,
"total_tokens": 0,
"components": [
{ "component": "gb_day", "unit": "gb_day", "quantity": 0.4, "cost_usd": null }
]
}
Read components whenever the bucket is not LLM usage: a storage,
api_request or compute_execution bucket has no tokens to report, so its
token fields are legitimately zero while quantity is what it measured. A
cost_usd of null means the component was not priced — the quantity was still
measured, and null never means free.
Components are not always disjoint, and one pair matters for the bill:
reasoning_tokens is the part of output_tokens a reasoning model spent
thinking. It is reported for visibility and priced as output, so its own
cost_usd is null to keep it from being counted twice — on a model that
reasons by default it can be most of the output, and most of the cost, of a
short answer. See
reasoning tokens are output tokens.
meter_type narrows the rollup to one meter. That is what to reach for with
group_by=model, whose dimension otherwise mixes model ids with platform SKUs
(gb_day sits next to a model name, because for platform meters the SKU is
the model field). The applied filter comes back on the response, null when
unfiltered.
- CLI
- SDK
- curl
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by model \
--meter-type llm_tokens
const { data: usage } = await naturali.projects.getProjectUsage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { group_by: 'model', meter_type: 'llm_tokens' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage?group_by=model&meter_type=llm_tokens" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Under group_by=model each bucket is keyed by the model name — the same
string GET /v1/models lists and an
agent is configured with, never a vendor's internal invocation
string.
The model dimension buckets on the model and the AI provider
that served it, so one model reached through two providers is two groups. Each
carries ai_provider_id, which is what tells them apart; it is null on every
other dimension. The groups still sum to the totals.
What each provider cost
group_by=ai_provider buckets on the provider the spend was billed against, and
key is the provider id. Reach for it instead of adding up the model
dimension's groups: a model route bills a generation
against the provider that actually served it rather than the agent's pinned one,
and one model name served by two providers is two model-dimension groups that
are easy to merge by mistake.
ai_provider_id is null here — on this dimension the provider is key.
- CLI
- SDK
- curl
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by ai_provider
const { data: usage } = await naturali.projects.getProjectUsage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { group_by: 'ai_provider' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage?group_by=ai_provider" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Narrowing to one session or one end user
group_by splits the project's whole spend, so the two questions a re-billing
view is actually built on have no dimension to ask for: what did this
conversation cost and what has this end user spent. session_id and
actor_id answer them by narrowing the rollup before it is bucketed — every
bucket and the top-level totals alike.
They compose with everything else. group_by=day&session_id=… is one
conversation's spend per day; actor_id=…&from=…&to=… is what one end user ran
up this cycle. Sending both narrows to that user's traffic within that one
session. Each is echoed back under filters, null when not applied, so a
rollup of zeros is never mistaken for a project that spent nothing.
Cap spend per end user
reads spend per actor before capping it.
An id naming nothing in this project yields an empty rollup, never the project total — a figure read as one session's cannot turn out to be everyone's.
- CLI
- SDK
- curl
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by day \
--session-id sess_V1StGXR8Z5jdHi6B
const { data: usage } = await naturali.projects.getProjectUsage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { group_by: 'day', session_id: 'sess_V1StGXR8Z5jdHi6B' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage?group_by=day&session_id=sess_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_TOKEN"
A single session read carries the same total as one number: see what a session cost. Reach for it when one figure is all you need, and for the meter when you need it split.
The rest of the narrowings
The other ten narrow the same way, and all of them intersect:
agent_id | one agent's traffic, across every session, run and trigger that dispatched it |
ai_provider_id | the spend billed against one provider record |
orchestration_run_id | the events one orchestration run metered |
orchestration_id | the runs one orchestration started itself — never the subtree a loop or sub_orchestration node started, which is metered where it was incurred |
generation_id | one generation's events |
trace_id | the events under one trace |
source | what the spend was for — eval, eval_judge |
trigger_id | the spend one trigger initiated |
action_id | one caller-supplied action label |
meter_type | one meter |
The eight naming a resource are resolved against the project and empty the
rollup when they name nothing in it. The rest match the value the event
recorded, so trigger_id still selects the spend of a trigger you have since
deleted. Give any of them twice and the request is refused rather than run
unnarrowed — the one way a filter could otherwise go unapplied without saying
so.
Every narrowing comes back under filters, always complete, null for each
one not applied. That is what lets a caller reading one key tell "not narrowed"
from "this meter does not know that narrowing".
There is no model narrowing. A model on this API has
one public name, translated at the edge, while the meter records
the vendor SKU that was billed. Passing a model name straight through would
match no event and answer an empty rollup — a silently wrong "nothing was
spent". Use group_by=model and read the bucket you want.
Who spent it, and on what kind of work
Three dimensions beyond the resource ones:
sessionandactorbucket on the conversation and the end user behind the spend.group_by=actoris the per-user bill a customer re-billing their own users sends;group_by=sessionis the same for one conversation at a time. Traffic with no end user behind it collapses into the singlenullbucket on both.sourcebuckets on what the spend was incurred for. Evaluation runs carryevaland their judgeseval_judge, so verification spend is separable from the traffic serving real users. Ordinary traffic carries no source and is thenullbucket.
- CLI
- SDK
- curl
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by actor
const { data: usage } = await naturali.projects.getProjectUsage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { group_by: 'actor' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage?group_by=actor" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Counting what a window touched
groups.total counts buckets. include=distinct counts the entities — one
figure per kind, over the whole narrowed window:
{
"generations": 812,
"traces": 800,
"orchestration_runs": 12,
"agents": 3,
"actors": 44,
"sessions": 96,
"ai_providers": 2
}
It is opt-in because each counter costs the window another sort, so a caller
who does not ask keeps the cheap response however large the event table grows.
Without it distinct is null — never zeroes, which would read as "none".
None of these add up. Two adjacent windows' sessions overlap wherever a
session spans the boundary, and a run that straddles midnight is in both days.
A wider figure is a wider query, never a sum of narrower ones. Nulls are not
counted either: work with no end user behind it raises event_count and
neither actors nor sessions.
Every bucket and the top-level total also carry event_count, the metered
events behind the cost — which tells one expensive call from a thousand cheap
ones. It is not a count of generations: one generation writes an event per
dimension it meters, so a call metering both tokens and compute writes two.
- CLI
- SDK
- curl
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by day \
--include distinct
const { data: usage } = await naturali.projects.getProjectUsage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { group_by: 'day', include: 'distinct' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage?group_by=day&include=distinct" \
-H "Authorization: Bearer $NATURALI_TOKEN"
The rows behind a number
GET /v1/projects/{project_id}/usage/events
returns the events a rollup summed, most recent first — one per metered
occurrence. It takes the same narrowings the meter does, so a query that
produced a bucket answers here unchanged and reads back what went into it.
Each row carries what was measured and against whom: meter_type, provider,
model, cost_usd, the components with their unit_price, and the
attribution — generation_id, trace_id, agent_id, session_id,
actor_id, ai_provider_id, orchestration_run_id, node_id, trigger_id,
action_id. That is the audit trail behind an invoice line.
- CLI
- SDK
- curl
naturali list-project-usage-events \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id sess_V1StGXR8Z5jdHi6B
const { data: events } = await naturali.projects.listProjectUsageEvents({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { session_id: 'sess_V1StGXR8Z5jdHi6B' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage/events?session_id=sess_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_TOKEN"
limit tops out at 100 and a larger value is refused rather than clamped, the
same as on the meter.
What one call was billed
GET /v1/projects/{project_id}/usage/receipt
itemises a single occurrence. Pass generation_id for one
generation, or orchestration_run_id for a receipt summed
across every event an orchestration run metered.
Exactly one of the two — neither, or both, is a 400.
Where the meter says a window cost $12.40, a receipt says which components of
which call made up one occurrence of it: per-event line_items with their
measured components and the unit_price each was charged at, a
by_meter_type split, and the totals.
On a run receipt every line carries node_id, so grouping by it gives the
per-node cost the total hides. A retried node contributes one line per
attempt — a retry is real money, and that is the intended reading.
- CLI
- SDK
- curl
naturali get-project-usage-receipt \
--project-id proj_V1StGXR8Z5jdHi6B \
--generation-id gen_V1StGXR8Z5jdHi6B
const { data: receipt } = await naturali.projects.getProjectUsageReceipt({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { generation_id: 'gen_V1StGXR8Z5jdHi6B' },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage/receipt?generation_id=gen_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_TOKEN"
An id naming nothing in this project answers 404 — which is also the answer
for an id belonging to someone else's project.
A receipt line carries no provider of its own, so the public model name is resolved by reading the events behind the receipt. Where that read cannot cover every line, all of them keep the string the meter recorded rather than some being renamed and some not — one honest naming scheme beats two in the same table.
Alerting on spend
A usage threshold is the one write on the meter: everywhere else usage is
something that already happened. Crossing one fires
usage.threshold_crossed — so a threshold is only useful alongside a
webhook subscribed to that event, and setting one without
that is an alert nobody receives.
metric | cost_usd across every meter, or tokens (input + output + cached) |
window | calendar_month (the current UTC month) or rolling_24h |
threshold | the value the windowed total must cross; greater than zero |
It fires once per window, not once per event past the line. A
calendar_month threshold alerts at most once in a month; a rolling_24h one
re-arms only when the total falls back below 90% of the threshold, so a
figure hovering at the line does not alert repeatedly.
Setting and deleting need the admin role — an alerting rule belongs to the
account that pays, not to one member. Reading the list needs member.
- CLI
- SDK
- curl
naturali create-project-usage-threshold \
--project-id proj_V1StGXR8Z5jdHi6B \
--metric cost_usd \
--window calendar_month \
--threshold 250
const { data: threshold } =
await naturali.projects.createProjectUsageThreshold({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { metric: 'cost_usd', window: 'calendar_month', threshold: 250 },
});
curl -X POST "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/usage/thresholds" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"metric":"cost_usd","window":"calendar_month","threshold":250}'
A threshold is an alert, not a cap: crossing it tells you, it does not stop anything. What stops spend is your plan's own limits — see a negative balance stops managed generation. A quota is a cap you set yourself: Cap a project's spend shows one refuse work.
Content retention
Two project-level controls govern what happens to the content of
generations — the prompts, tool arguments, tool results and
error payloads a run records. Both are set
with
PATCH /v1/projects/{project_id}.
They answer different questions, and the difference matters when you have to describe your data handling to someone else:
| Field | Guarantee | Content on disk? |
|---|---|---|
trace_content_retention_days | Content is purged once it is older than the window | Yes, until the sweep runs |
trace_content_mode: none | Content is never written | No, ever |
trace_content_retention_days bounds how long content stays. A daily
sweep content-purges everything past the window using the same path as an
on-demand purge, so a swept record survives as an auditable skeleton with
content_redacted_at set — see
Purging content. null disables the sweep,
and content is kept until purged explicitly — which your plan may not allow, as
below.
trace_content_mode: none is zero-retention: content is never written in
the first place, for every agent in the project. That is a stronger claim than
deletion — content that was never stored cannot be missed by a sweep or
survive in a backup — and it is what you want when the content itself must not
land on disk at all.
Run a zero-retention agent
sets it on one agent and
compares its transcript with a storing one.
An agent may tighten to none under a storing project, but
cannot loosen a none project back to full; that is refused with
400 invalid_trace_content_mode. Otherwise a project-wide zero-retention
mandate could be escaped just by creating a new agent.
Switching a project to none stops future writes; it does not erase content
already recorded. To clear the backlog, purge it explicitly or set a retention
window and let the sweep do it.
The window your plan allows
Your plan sets the longest window a project may keep content for:
| Free | Pro | Business | Enterprise |
|---|---|---|---|
| 7 days | 30 days | 90 days | custom |
A new project starts at that figure. Any shorter window is always allowed —
this is a ceiling, not a setting, and a window you tighten is never widened back
for you. A longer one answers 403 plan_limit_reached, with the plan and the
limit in days in details:
{
"error": {
"code": "plan_limit_reached",
"message": "The free plan keeps trace content for 7 days.",
"details": { "plan": "free", "resource": "retention", "limit": 7 }
}
}
trace_content_retention_days: null is refused the same way on any plan that
sets a window. It reads like an "off" switch, but it turns the sweep off, so
content is kept indefinitely — the longest window there is, not the shortest.
On a custom plan the window is whatever your contract says, and it is
enforced exactly like the figures above: shorter is always allowed, longer
answers 403 plan_limit_reached naming the contract's figure as the limit.
A contract that keeps content indefinitely is recorded as such, and then
null is accepted. Read the window in force with
GET /v1/users/me/billing, whose
retention_days is the same number a refusal would name — null when nothing
bounds it.
The shorter window applies at the end of the billing cycle, not when the plan changes — you keep the window you paid for until the cycle you paid for is over. Moving to a longer window applies immediately.
Once the shorter window does apply, it purges by content age like any other window, so content recorded under the old window is purged too. Export anything you need to keep before the cycle ends.
Note that these settings govern what naturali stores. What the model provider does with the content it receives is governed by your agreement with that provider — for a provider whose key you brought (AI Providers), directly with them.
Execution ceilings
Three project-level bounds on how much work a project can have in flight, all
set with
PATCH /v1/projects/{project_id}:
| Field | Bounds | Past it |
|---|---|---|
max_concurrent_runs | Orchestration runs driven at once | Runs wait for a slot |
max_chain_generations | Generations one continuation chain may hold | The chain stops being resumed |
max_orchestration_run_depth | Nesting levels a run tree may reach | The next child is refused |
These are yours to set on any plan, and nothing counts them against an allowance: each one only ever narrows what the project can spend, so setting one takes on a restriction rather than claiming an entitlement.
They bound spend where a balance check cannot. A negative balance stops managed generation when a run starts, but a chain that keeps resuming itself, or an orchestration that keeps nesting deeper, spends inside a run that already passed that check. A ceiling is what bounds those.
max_chain_generations and max_orchestration_run_depth are the stricter of
your figure and the platform's, so setting one can only tighten what already
applies. null clears your figure and leaves the platform's; omitting the
field leaves your figure alone — they are different instructions.
An agent can be stricter than max_chain_generations with its
own stop condition. It cannot be looser.
Requiring a priced model
require_priced_model: true refuses a generation whose model carries no price,
before the provider is called — so a refused call is never made, never
recorded and never metered. The refusal names each (provider, model, component) that needs a price.
- CLI
- SDK
- curl
naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--require-priced-model true
const { data: project } = await naturali.projects.updateProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { require_priced_model: true },
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "require_priced_model": true }'
It is false by default, which runs the model and meters it at no cost. That
is a silent gap rather than a free call: the usage really happened, and
your usage meter reports cost_usd: null for it, so the
spend is invisible to every total you read.
Both billable token components are checked, input_tokens and output_tokens
— a model priced for one and not the other meters a cost that understates
itself. An agent bound to a model route is held to every
target, not just the first, because a failover bills whichever target answers.
Embeddings are never gated.
Models on naturali's own offering are priced from the catalog, and managed generation already refuses an unpriced model on its own — you do not need this switch for them.
Where it earns its place is a provider whose key you brought
(AI Providers): nothing prices those models for you, so
without this the spend meters at cost_usd: null and your own cost reporting
quietly undercounts. Price them first with
PUT /v1/projects/{project_id}/ai-providers/{ai_provider_id}/prices,
then turn the switch on — in that order, or every generation on an unpriced
model is refused until you do.
Project-scope guardrails
guardrail_ids on the project attaches guardrails under
every tool call by every agent in it — including tools and agents created
later, which a per-tool attachment
would miss. It sits alongside the tool and agent scopes, and the strictest decision across all three wins.
- CLI
- SDK
- curl
naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--guardrail-ids guard_V1StGXR8Z5jdHi6B
const { data: project } = await naturali.projects.updateProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { guardrail_ids: ['guard_V1StGXR8Z5jdHi6B'] },
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "guardrail_ids": ["guard_V1StGXR8Z5jdHi6B"] }'
The list is replaced wholesale: send the ids you want to keep, or [] to
detach every one. An id naming no guardrail in the project answers
400 guardrail_not_found, with the ids in details.missing. Like every other
field on the project it needs the admin role.
No plan is read. Guardrails are created from the Pro plan, but a project below it can still attach and detach existing ones — a rule that is enforced wherever it is attached must always be removable.
Pausing a project
POST /v1/projects/{project_id}/pause is a
kill switch: one call stops everything the project runs, and
POST /v1/projects/{project_id}/resume
hands it back. Both need the admin role. An automation can fire the pause
with a project API key minted by an admin, for example on a
spend anomaly.
- CLI
- SDK
- curl
naturali pause-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--reason "spend anomaly"
naturali resume-project --project-id proj_V1StGXR8Z5jdHi6B
await naturali.projects.pauseProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { reason: 'spend anomaly' },
});
await naturali.projects.resumeProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/pause \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "spend anomaly" }'
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/resume \
-H "Authorization: Bearer $NATURALI_TOKEN"
While paused_at is set:
| What | Under the pause | On resume |
|---|---|---|
| Generations, tool calls, orchestration and eval run starts, trigger fires | 409 with PROJECT_PAUSED; nothing runs | Accepted again |
| Resuming one run or task the pause holds | 409 with PROJECT_PAUSED | Handed back by the project's resume |
| Channel messages | Answered with a neutral "can't reply right now" line in the action's language; nobody is emailed | Answered by the agent again |
| Live orchestration runs | Paused at their next checkpoint, with the pause's reason | Resumed |
| Open tasks | Their automation is paused; transitions still work | Suppressed dispatches run |
| Schedule triggers | Not fired | Fire from their next occurrence after now |
| Queued eval items | Wait | Continue where they stopped |
A generation already running finishes. Reads and configuration changes keep
working, so the project can be inspected and fixed while paused. Pausing is
idempotent and keeps the first reason; resuming a project that is not paused
answers 409 with project_not_paused. A run or task you paused on its own
before pausing the project keeps its own pause.
Both changes emit a webhook event:
project.paused and project.resumed.
Deletion
Deleting a project with dependent resources returns 409 conflict; pass
force=true to delete everything underneath it, its channels, contacts and
webhooks included — destructive and irreversible.
Examples
- CLI
- SDK
- curl
naturali create-project --name acme-corp
const { data: project } = await naturali.projects.createProject({
body: { name: 'acme-corp' },
});
curl -X POST https://api.naturali.ai/v1/projects \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "acme-corp" }'
Putting a project into zero-retention, with a 30-day window for anything already recorded (30 days needs Pro or above — see The window your plan allows):
- CLI
- SDK
- curl
naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--trace-content-mode none \
--trace-content-retention-days 30
const { data: project } = await naturali.projects.updateProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
trace_content_mode: 'none',
trace_content_retention_days: 30,
},
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "trace_content_mode": "none", "trace_content_retention_days": 30 }'
Omitting trace_content_retention_days leaves the current window untouched —
null and absent are different instructions, and null means "keep
indefinitely", which only a custom plan allows.