# Data Retention and Zero-Retention

> Erase prompt and completion content from agent logs on demand, expire it with a retention window, or never store it, while cost and audit records stay.

SOAT separates **content** ([trace](/docs/modules/traces) steps, a [generation](/docs/modules/generations)'s `metadata`, `error` and `extraction`) from **skeleton** (ids, timestamps, `status`, `stop_reason`, token counts, cost, usage-attribution fields). Every mechanism here destroys or never writes the first and preserves the second, so an erasure never costs a billing record.

## Prerequisites

- SOAT running locally. Follow the [Quick Start](/docs/getting-started) guide to bring the stack up with Docker Compose.
- [Ollama](https://ollama.com) running locally with `qwen2.5:0.5b` available. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see [Connect Third-Party LLMs](/docs/tutorials/connect-third-party-llms).
- New to SOAT? Read [Key Concepts](/docs/getting-started/concepts) to understand projects, agents, and sessions first.
- CLI installed and configured, or SDK set up. See [CLI](/docs/cli) or [SDK](/docs/sdk).
- For production hardening (secrets, env vars), see [Configuration](/docs/self-hosting/configuration).
- Server is at `http://localhost:5047`.

**CLI**

```bash
export SOAT_BASE_URL=http://localhost:5047
```

**SDK**

```ts
import { SoatClient } from '@soat/sdk';
```

**curl**

```bash
export SOAT_BASE_URL=http://localhost:5047
```

---

## Step 1 — Log in as admin

Changing a project requires the admin role. See [Users](/docs/modules/users#examples) for authentication details.

**CLI**

```bash
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
```

**SDK**

```ts
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });

const { data: login } = await soat.users.loginUser({
  body: { username: 'admin', password: 'Admin1234!' },
});

const adminSoat = new SoatClient({
  baseUrl: 'http://localhost:5047',
  token: login.token,
});
```

**curl**

```bash
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
```

---

## Step 2 — Create a project, provider, and agent

Retention is scoped to the [project](/docs/modules/projects).

**CLI**

```bash
PROJECT_ID=$(soat create-project --name "Retention Demo" | jq -r '.id')

AI_PROVIDER_ID=$(soat create-ai-provider \
  --project-id "$PROJECT_ID" \
  --name "Local Ollama" \
  --provider "ollama" \
  --default-model "qwen2.5:0.5b" | jq -r '.id')

AGENT_ID=$(soat create-agent \
  --project-id "$PROJECT_ID" \
  --ai-provider-id "$AI_PROVIDER_ID" \
  --name "Support Bot" \
  --instructions "You are a concise assistant. Answer in one short sentence." | jq -r '.id')

echo "PROJECT_ID: $PROJECT_ID"
echo "AGENT_ID: $AGENT_ID"
```

**SDK**

```ts
const { data: project } = await adminSoat.projects.createProject({
  body: { name: 'Retention Demo' },
});

const { data: provider } = await adminSoat.aiProviders.createAiProvider({
  body: {
    project_id: project.id,
    name: 'Local Ollama',
    provider: 'ollama',
    default_model: 'qwen2.5:0.5b',
  },
});

const { data: agent } = await adminSoat.agents.createAgent({
  body: {
    project_id: project.id,
    ai_provider_id: provider.id,
    name: 'Support Bot',
    instructions: 'You are a concise assistant. Answer in one short sentence.',
  },
});
```

**curl**

```bash
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Retention Demo"}' | jq -r '.id')

AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Local Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" | jq -r '.id')

AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"project_id\":\"$PROJECT_ID\",\"ai_provider_id\":\"$AI_PROVIDER_ID\",\"name\":\"Support Bot\",\"instructions\":\"You are a concise assistant. Answer in one short sentence.\"}" | jq -r '.id')
```

---

## Step 3 — Run a turn, and confirm the content is stored

Send a message with sensitive content, then read the trace back. `file_id` points to the stored steps object; `content_redacted_at` is `null` while the content is intact.

**CLI**

```bash
SESSION_ID=$(soat create-session --agent-id "$AGENT_ID" \
  --name "Retention session" --auto-generate false | jq -r '.id')

soat add-session-message --session-id "$SESSION_ID" \
  --message "My account number is 4455-9982. What are your support hours?" > /dev/null

TURN=$(soat generate-session-response --wait true --session-id "$SESSION_ID")
GENERATION_ID=$(printf '%s\n' "$TURN" | jq -r '.generation_id')
TRACE_ID=$(printf '%s\n' "$TURN" | jq -r '.trace_id')

soat get-trace --trace-id "$TRACE_ID" \
  | jq '{id, file_id, step_count, content_redacted_at}'
```

The steps object holds the whole exchange, account number included:

```bash
TRACE_FILE_ID=$(soat get-trace --trace-id "$TRACE_ID" | jq -r '.file_id')
soat download-file-base64 --file-id "$TRACE_FILE_ID" \
  | jq -r '.content' | base64 -d | jq 'length'
```

**SDK**

```ts
const { data: session } = await adminSoat.sessions.createSession({
  body: { agent_id: agent.id, name: 'Retention session', auto_generate: false },
});

await adminSoat.sessions.addSessionMessage({
  path: { session_id: session.id },
  body: { message: 'My account number is 4455-9982. What are your support hours?' },
});

const { data: turn } = await adminSoat.sessions.generateSessionResponse({
  path: { session_id: session.id },
  query: { wait: true },
  body: {},
});

const { data: trace } = await adminSoat.traces.getTrace({
  path: { trace_id: turn.trace_id },
});
console.log(trace.file_id, trace.content_redacted_at); // 'file_...', null

const { data: traceFile } = await adminSoat.files.downloadFileBase64({
  path: { file_id: trace.file_id },
});
const steps = JSON.parse(
  Buffer.from(traceFile.content, 'base64').toString('utf8')
);
```

**curl**

```bash
SESSION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"agent_id\":\"$AGENT_ID\",\"name\":\"Retention session\",\"auto_generate\":false}" | jq -r '.id')

curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$SESSION_ID/messages" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"message":"My account number is 4455-9982. What are your support hours?"}' > /dev/null

TURN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$SESSION_ID/generate?wait=true" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d '{}')

GENERATION_ID=$(printf '%s\n' "$TURN" | jq -r '.generation_id')
TRACE_ID=$(printf '%s\n' "$TURN" | jq -r '.trace_id')

curl -s "$SOAT_BASE_URL/api/v1/traces/$TRACE_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '{id, file_id, step_count, content_redacted_at}'
```

Expected output (ids will differ):

```json
{
  "id": "trace_m9u9kHBRpiZ2BTSg",
  "file_id": "file_VsczNprUw29Hd8Nw",
  "step_count": 1,
  "content_redacted_at": null
}
```

---

## Step 4 — Purge the trace on request

`purge-trace-content` deletes the steps object from storage and clears the trace's content columns. It requires the `traces:PurgeTraceContent` action.

**CLI**

```bash
soat purge-trace-content --trace-id "$TRACE_ID" \
  | jq '{id, file_id, step_count, content_redacted_at, content_redacted_by_principal_type, content_redacted_by_principal_id}'
```

**SDK**

```ts
const { data: purgedTrace } = await adminSoat.traces.purgeTraceContent({
  path: { trace_id: turn.trace_id },
});

console.log(purgedTrace.file_id); // null
console.log(purgedTrace.content_redacted_at); // '2026-08-06T...'
console.log(purgedTrace.content_redacted_by_principal_type); // 'user'
```

**curl**

```bash
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/traces/$TRACE_ID/content" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '{id, file_id, step_count, content_redacted_at, content_redacted_by_principal_type, content_redacted_by_principal_id}'
```

Expected output:

```json
{
  "id": "trace_m9u9kHBRpiZ2BTSg",
  "file_id": null,
  "step_count": 1,
  "content_redacted_at": "2026-08-06T15:13:49.096Z",
  "content_redacted_by_principal_type": "user",
  "content_redacted_by_principal_id": "user_vAo7SYeHGV6DewYz"
}
```

Three things happened:

- **The row survived as a skeleton** carrying `content_redacted_at`, so the erasure is provable to an auditor (a `404` would be indistinguishable from a resource that never existed).
- **The bytes are gone.** The steps object and its `File` row were deleted from storage. Fetching it now fails:

**CLI**

```bash
# → expect-fail
soat download-file-base64 --file-id "$TRACE_FILE_ID"
```

Purging again is an idempotent no-op that keeps the original timestamp:

```bash
soat purge-trace-content --trace-id "$TRACE_ID" | jq '{content_redacted_at}'
```

**SDK**

```ts
const { error } = await adminSoat.files.downloadFileBase64({
  path: { file_id: trace.file_id },
});
console.log(error); // 404 — the object and its File row are gone

// Idempotent: a second purge keeps the original content_redacted_at.
await adminSoat.traces.purgeTraceContent({ path: { trace_id: turn.trace_id } });
```

**curl**

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
  "$SOAT_BASE_URL/api/v1/files/$TRACE_FILE_ID/download/base64" \
  -H "Authorization: Bearer $ADMIN_TOKEN"   # 404

curl -s -X DELETE "$SOAT_BASE_URL/api/v1/traces/$TRACE_ID/content" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq '{content_redacted_at}'
```

- **It cascaded.** Every descendant trace and its generations were purged too; a nested agent call writes its own steps object.

---

## Step 5 — Purge a single generation

`purge-generation-content` clears one generation's `metadata`, `error`, `extraction` and the internal recovery state of a paused run, without touching sibling generations. It requires `generations:PurgeGenerationContent`.

**CLI**

```bash
soat purge-generation-content --generation-id "$GENERATION_ID" \
  | jq '{id, status, stop_reason, metadata, content_redacted_at}'
```

**SDK**

```ts
const { data: purgedGeneration } =
  await adminSoat.generations.purgeGenerationContent({
    path: { generation_id: turn.generation_id },
  });

console.log(purgedGeneration.metadata); // null
console.log(purgedGeneration.status); // 'completed' — skeleton preserved
```

**curl**

```bash
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/generations/$GENERATION_ID/content" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '{id, status, stop_reason, metadata, content_redacted_at}'
```

:::warning
A generation purge does **not** delete the parent trace's steps object, which holds this generation's content alongside its siblings'. To erase a run completely, purge the **trace**.
:::

---

## Step 6 — Confirm the ledger survived

The [usage](/docs/modules/usage) meter for the purged generation is intact.

**CLI**

```bash
soat list-usage-events --generation-id "$GENERATION_ID" \
  | jq '.data[0] | {generation_id, model, meter_type, components}'

soat get-generation --generation-id "$GENERATION_ID" \
  | jq '{id, status, agent_version, created_at, content_redacted_at, metadata}'
```

**SDK**

```ts
const { data: meters } = await adminSoat.usage.listUsageEvents({
  query: { generation_id: turn.generation_id },
});
console.log(meters.data[0].components); // token counts, still there

const { data: skeleton } = await adminSoat.generations.getGeneration({
  path: { generation_id: turn.generation_id },
});
console.log(skeleton.status, skeleton.metadata); // 'completed', null
```

**curl**

```bash
curl -s "$SOAT_BASE_URL/api/v1/usage/events?generation_id=$GENERATION_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '.data[0] | {generation_id, model, meter_type, components}'

curl -s "$SOAT_BASE_URL/api/v1/generations/$GENERATION_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '{id, status, agent_version, created_at, content_redacted_at, metadata}'
```

Ids, timestamps, `status`, `stop_reason`, token counts, cost, and every attribution field (`action_id`, `trigger_id`, `orchestration_run_id`, `node_id`, `agent_version`, `routing`) are preserved. `cost_usd` is `null` above only because this local model has no price rows (see [Metering and Budgets](/docs/tutorials/metering-and-budgets)); the purge does not touch it either way.

---

## Step 7 — Automate it with a retention window

`trace_content_retention_days` on the project makes erasure a policy: a daily sweep content-purges every trace in the project older than the window.

**CLI**

```bash
soat update-project --project-id "$PROJECT_ID" \
  --trace-content-retention-days 30 \
  | jq '{id, trace_content_retention_days}'
```

**SDK**

```ts
const { data: withRetention } = await adminSoat.projects.updateProject({
  path: { project_id: project.id },
  body: { trace_content_retention_days: 30 },
});
console.log(withRetention.trace_content_retention_days); // 30
```

**curl**

```bash
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"trace_content_retention_days":30}' \
  | jq '{id, trace_content_retention_days}'
```

What the sweep guarantees:

- **Opt-in.** `null` is the default and disables retention.
- **One purge implementation.** The sweep uses the same code path as Step 4: same cascade, byte deletion, `content_redacted_at` semantics, audit entries and `traces.content_purged` events.
- **A run is purged as a unit.** The sweep selects root traces; the subtree goes with the root.
- **Auditable.** Sweep purges are stamped `content_redacted_by_principal_type: "system"` / `content_redacted_by_principal_id: "retention_sweep"`.

The sweep schedule is server configuration; see [Traces — Configuration](/docs/modules/traces#configuration) for `CONTENT_RETENTION_SWEEP_INTERVAL_MS` and `CONTENT_RETENTION_SWEEP_DISABLED`.

Clear the window with `null` to keep content until purged on demand:

**CLI**

```bash
soat update-project --project-id "$PROJECT_ID" \
  --trace-content-retention-days null \
  | jq '{trace_content_retention_days}'
```

**SDK**

```ts
await adminSoat.projects.updateProject({
  path: { project_id: project.id },
  body: { trace_content_retention_days: null },
});
```

**curl**

```bash
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"trace_content_retention_days":null}' | jq '{trace_content_retention_days}'
```

---

## Step 8 — Zero-retention for one agent

Retention deletes content after the fact; zero-retention never writes it. Create a second agent with `trace_content_mode: none`.

**CLI**

```bash
INTAKE_AGENT_ID=$(soat create-agent \
  --project-id "$PROJECT_ID" \
  --ai-provider-id "$AI_PROVIDER_ID" \
  --name "Regulated Intake" \
  --instructions "You are a concise intake assistant. Answer in one short sentence." | jq -r '.id')

soat patch-agent --agent-id "$INTAKE_AGENT_ID" --trace-content-mode none \
  | jq '{id, trace_content_mode}'
```

**SDK**

```ts
const { data: intakeAgent } = await adminSoat.agents.createAgent({
  body: {
    project_id: project.id,
    ai_provider_id: provider.id,
    name: 'Regulated Intake',
    instructions:
      'You are a concise intake assistant. Answer in one short sentence.',
  },
});

await adminSoat.agents.patchAgent({
  path: { agent_id: intakeAgent.id },
  body: { trace_content_mode: 'none' },
});
```

**curl**

```bash
INTAKE_AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"project_id\":\"$PROJECT_ID\",\"ai_provider_id\":\"$AI_PROVIDER_ID\",\"name\":\"Regulated Intake\",\"instructions\":\"You are a concise intake assistant. Answer in one short sentence.\"}" | jq -r '.id')

curl -s -X PATCH "$SOAT_BASE_URL/api/v1/agents/$INTAKE_AGENT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"trace_content_mode":"none"}' | jq '{id, trace_content_mode}'
```

Run a turn through it. The reply still reaches the caller; only the durable record is a skeleton.

**CLI**

```bash
INTAKE_SESSION_ID=$(soat create-session --agent-id "$INTAKE_AGENT_ID" \
  --name "Intake session" --auto-generate false | jq -r '.id')

soat add-session-message --session-id "$INTAKE_SESSION_ID" \
  --message "My card ends in 4242. Is my payment late?" > /dev/null

INTAKE_TURN=$(soat generate-session-response --wait true --session-id "$INTAKE_SESSION_ID")
INTAKE_GENERATION_ID=$(printf '%s\n' "$INTAKE_TURN" | jq -r '.generation_id')
INTAKE_TRACE_ID=$(printf '%s\n' "$INTAKE_TURN" | jq -r '.trace_id')

printf '%s\n' "$INTAKE_TURN" | jq '{status}'

soat get-trace --trace-id "$INTAKE_TRACE_ID" \
  | jq '{id, file_id, content_redacted_at, content_redacted_by_principal_type, content_redacted_by_principal_id}'

soat get-generation --generation-id "$INTAKE_GENERATION_ID" \
  | jq '{id, status, metadata, content_redacted_by_principal_id}'
```

**SDK**

```ts
const { data: intakeSession } = await adminSoat.sessions.createSession({
  body: {
    agent_id: intakeAgent.id,
    name: 'Intake session',
    auto_generate: false,
  },
});

await adminSoat.sessions.addSessionMessage({
  path: { session_id: intakeSession.id },
  body: { message: 'My card ends in 4242. Is my payment late?' },
});

const { data: intakeTurn } = await adminSoat.sessions.generateSessionResponse({
  path: { session_id: intakeSession.id },
  query: { wait: true },
  body: {},
});

const { data: intakeTrace } = await adminSoat.traces.getTrace({
  path: { trace_id: intakeTurn.trace_id },
});
console.log(intakeTrace.file_id); // null — no steps object was ever written
console.log(intakeTrace.content_redacted_by_principal_id); // 'zero_retention'
```

**curl**

```bash
INTAKE_SESSION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"agent_id\":\"$INTAKE_AGENT_ID\",\"name\":\"Intake session\",\"auto_generate\":false}" | jq -r '.id')

curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$INTAKE_SESSION_ID/messages" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"message":"My card ends in 4242. Is my payment late?"}' > /dev/null

INTAKE_TURN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$INTAKE_SESSION_ID/generate?wait=true" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d '{}')

INTAKE_TRACE_ID=$(printf '%s\n' "$INTAKE_TURN" | jq -r '.trace_id')

curl -s "$SOAT_BASE_URL/api/v1/traces/$INTAKE_TRACE_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | jq '{id, file_id, content_redacted_at, content_redacted_by_principal_id}'
```

Expected output — the marker is set from the moment the row exists:

```json
{
  "id": "trace_ZfPUnZBnUR3JAg1z",
  "file_id": null,
  "content_redacted_at": "2026-08-06T15:13:55.490Z",
  "content_redacted_by_principal_type": "system",
  "content_redacted_by_principal_id": "zero_retention"
}
```

The principal id distinguishes never stored (`zero_retention`) from stored-then-erased (a user, an API key, or `retention_sweep`). The skeleton is written unchanged, so metering is unaffected:

**CLI**

```bash
soat list-usage-events --generation-id "$INTAKE_GENERATION_ID" \
  | jq '.data[0] | {model, meter_type, components}'
```

**SDK**

```ts
const { data: intakeMeters } = await adminSoat.usage.listUsageEvents({
  query: { generation_id: intakeTurn.generation_id },
});
console.log(intakeMeters.data[0].components); // metered exactly as usual
```

**curl**

```bash
curl -s "$SOAT_BASE_URL/api/v1/usage/events?generation_id=$(printf '%s\n' "$INTAKE_TURN" | jq -r '.generation_id')" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data[0] | {model, meter_type, components}'
```

:::warning[Trade-off: no recovery after a restart]
The state that resumes a generation paused on a [client tool](/docs/tutorials/client-tools) is content, so it is not persisted in this mode. A generation still pauses and resumes within a running server, but **a generation paused when the server restarts cannot be recovered**. If restart-recovery matters more, use a retention window.
:::

---

## Step 9 — Make it a project-wide mandate

`trace_content_mode: none` on the project applies zero-retention to every agent in it, including agents created later.

**CLI**

```bash
REGULATED_PROJECT_ID=$(soat create-project --name "Regulated Tenant" | jq -r '.id')

soat update-project --project-id "$REGULATED_PROJECT_ID" \
  --trace-content-mode none | jq '{id, trace_content_mode}'

REGULATED_PROVIDER_ID=$(soat create-ai-provider \
  --project-id "$REGULATED_PROJECT_ID" \
  --name "Local Ollama" \
  --provider "ollama" \
  --default-model "qwen2.5:0.5b" | jq -r '.id')

REGULATED_AGENT_ID=$(soat create-agent \
  --project-id "$REGULATED_PROJECT_ID" \
  --ai-provider-id "$REGULATED_PROVIDER_ID" \
  --name "Tenant Agent" \
  --instructions "You are a concise assistant." | jq -r '.id')
```

The project is a floor: an agent may tighten to `none` but cannot loosen back to `full`.

```bash
# → expect-fail
soat patch-agent --agent-id "$REGULATED_AGENT_ID" --trace-content-mode full
```

**SDK**

```ts
const { data: regulatedProject } = await adminSoat.projects.createProject({
  body: { name: 'Regulated Tenant' },
});

await adminSoat.projects.updateProject({
  path: { project_id: regulatedProject.id },
  body: { trace_content_mode: 'none' },
});

const { data: regulatedProvider } = await adminSoat.aiProviders.createAiProvider(
  {
    body: {
      project_id: regulatedProject.id,
      name: 'Local Ollama',
      provider: 'ollama',
      default_model: 'qwen2.5:0.5b',
    },
  }
);

const { data: tenantAgent } = await adminSoat.agents.createAgent({
  body: {
    project_id: regulatedProject.id,
    ai_provider_id: regulatedProvider.id,
    name: 'Tenant Agent',
    instructions: 'You are a concise assistant.',
  },
});

// The project is a floor — loosening back to `full` is refused.
const { error } = await adminSoat.agents.patchAgent({
  path: { agent_id: tenantAgent.id },
  body: { trace_content_mode: 'full' },
});
console.log(error); // 400 VALIDATION_FAILED
```

**curl**

```bash
REGULATED_PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Regulated Tenant"}' | jq -r '.id')

curl -s -X PATCH "$SOAT_BASE_URL/api/v1/projects/$REGULATED_PROJECT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"trace_content_mode":"none"}' | jq '{id, trace_content_mode}'

# ... create a provider and an agent in that project, then:
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/agents/$REGULATED_AGENT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"trace_content_mode":"full"}' | jq '{error, code}'   # 400 VALIDATION_FAILED
```

An agent's `null` (the default) inherits the project; resolution fails closed: an unrecognised stored mode resolves to `none`.

---

Read next: [Traces — Content Purge, Retention Policy, Zero-Retention](/docs/modules/traces#content-purge), [Generations — Content Purge](/docs/modules/generations#content-purge), [Projects](/docs/modules/projects), and [Audit Log](/docs/modules/audit-log) for the entries every purge writes.
