Skip to main content

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.

How many you may own depends on your plan

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​

FieldTypeDescription
idstringPublic project ID (proj_ prefix).
namestringHuman-readable label.
statusstringLifecycle status.
rolestringThe caller's role in this project: owner, admin or member — see Membership and the billing owner.
owner_user_idstringThe user who pays for the project.
trace_content_retention_daysinteger, nullableDays 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_modestringfull (the default) stores trace and generation content; none is zero-retention — see Content retention.
max_concurrent_runsinteger, nullableOrchestration runs driven at once. null (the default) is unlimited — see Execution ceilings.
max_chain_generationsinteger, nullableGenerations one continuation chain may hold before it stops being resumed. null (the default) leaves the platform-wide ceiling — see Execution ceilings.
max_orchestration_run_depthinteger, nullableNesting 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_modelbooleanRefuses a generation whose model carries no price, before the provider is called. false by default — see Requiring a priced model.
guardrail_idsstring[]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_atstring (date-time), nullableWhen the project was paused; null while it runs — see Pausing a project.
pause_reasonstring, nullableThe reason the pause named, up to 256 characters; null while the project runs or when the pause named none.
managed_conversionbooleanWhether scanned PDFs, images and audio are converted on ingest with no setup. true by default — see Managed conversion.
created_atstring (date-time)
updated_atstring (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:

SituationAnswer
The caller is not a member404 — so one user's projects cannot be probed out of another's
The caller is a member, but the role is too narrow403, naming the role the action needs
The credential is scoped to a different project403

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:

Actionowneradminmember
Read and write the project's resourcesyesyesyes
Mint a project API keyyesyesyes
Rename or reconfigure the projectyesyesno
Add a memberyesyesno
Add an adminyesnono
Change a roleyesnono
Remove a memberyesyesonly themselves
Remove an adminyesnoonly themselves
Remove the ownerno — transfer the project insteadnono

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.

naturali add-project-member \
--project-id proj_V1StGXR8Z5jdHi6B \
--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.

naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by model \
--meter-type llm_tokens

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.

naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by ai_provider

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.

naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by day \
--session-id sess_V1StGXR8Z5jdHi6B

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_idone agent's traffic, across every session, run and trigger that dispatched it
ai_provider_idthe spend billed against one provider record
orchestration_run_idthe events one orchestration run metered
orchestration_idthe runs one orchestration started itself — never the subtree a loop or sub_orchestration node started, which is metered where it was incurred
generation_idone generation's events
trace_idthe events under one trace
sourcewhat the spend was for — eval, eval_judge
trigger_idthe spend one trigger initiated
action_idone caller-supplied action label
meter_typeone 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".

note

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:

  • session and actor bucket on the conversation and the end user behind the spend. group_by=actor is the per-user bill a customer re-billing their own users sends; group_by=session is the same for one conversation at a time. Traffic with no end user behind it collapses into the single null bucket on both.
  • source buckets on what the spend was incurred for. Evaluation runs carry eval and their judges eval_judge, so verification spend is separable from the traffic serving real users. Ordinary traffic carries no source and is the null bucket.
naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by actor

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.

naturali get-project-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by day \
--include distinct

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.

naturali list-project-usage-events \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id sess_V1StGXR8Z5jdHi6B

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.

naturali get-project-usage-receipt \
--project-id proj_V1StGXR8Z5jdHi6B \
--generation-id gen_V1StGXR8Z5jdHi6B

An id naming nothing in this project answers 404 — which is also the answer for an id belonging to someone else's project.

note

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.

metriccost_usd across every meter, or tokens (input + output + cached)
windowcalendar_month (the current UTC month) or rolling_24h
thresholdthe 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.

naturali create-project-usage-threshold \
--project-id proj_V1StGXR8Z5jdHi6B \
--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:

FieldGuaranteeContent on disk?
trace_content_retention_daysContent is purged once it is older than the windowYes, until the sweep runs
trace_content_mode: noneContent is never writtenNo, 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.

The project mode is a floor, not a default

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:

FreeProBusinessEnterprise
7 days30 days90 dayscustom

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.

Moving to a plan with a shorter window

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

FieldBoundsPast it
max_concurrent_runsOrchestration runs driven at onceRuns wait for a slot
max_chain_generationsGenerations one continuation chain may holdThe chain stops being resumed
max_orchestration_run_depthNesting levels a run tree may reachThe 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.

naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--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.

Mainly of use with your own providers

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.

naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--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.

naturali pause-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--reason "spend anomaly"
naturali resume-project --project-id proj_V1StGXR8Z5jdHi6B

While paused_at is set:

WhatUnder the pauseOn resume
Generations, tool calls, orchestration and eval run starts, trigger fires409 with PROJECT_PAUSED; nothing runsAccepted again
Resuming one run or task the pause holds409 with PROJECT_PAUSEDHanded back by the project's resume
Channel messagesAnswered with a neutral "can't reply right now" line in the action's language; nobody is emailedAnswered by the agent again
Live orchestration runsPaused at their next checkpoint, with the pause's reasonResumed
Open tasksTheir automation is paused; transitions still workSuppressed dispatches run
Schedule triggersNot firedFire from their next occurrence after now
Queued eval itemsWaitContinue 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​

naturali create-project --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):

naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--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.