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.
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. |
trace_content_retention_days | integer, nullable | Days of trace and generation content retention before a daily sweep purges it. null (the default) disables retention — see Content retention. |
trace_content_mode | string | full (the default) stores trace and generation content; none is zero-retention — see Content retention. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
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.
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.
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_API_KEY"
Content retention
Two project-level controls govern what happens to the content of
traces and 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 trace content. null (the
default) disables the sweep, and content is kept until purged explicitly.
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.
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.
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 BYOK provider, directly with them.
Deletion
Deleting a project with dependent resources returns 409 conflict; pass
force=true to delete everything underneath it — 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_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "acme-corp" }'
Putting a project into zero-retention, with a 90-day window for anything already recorded:
- CLI
- SDK
- curl
naturali update-project \
--project-id proj_V1StGXR8Z5jdHi6B \
--trace-content-mode none \
--trace-content-retention-days 90
const { data: project } = await naturali.projects.updateProject({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
trace_content_mode: 'none',
trace_content_retention_days: 90,
},
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "trace_content_mode": "none", "trace_content_retention_days": 90 }'
Send trace_content_retention_days: null to turn the sweep off again. Omitting
the field leaves the current window untouched — null and absent are different
instructions.