Skip to main content

Data Retention and Zero-Retention

Two questions block most AI rollouts inside a regulated company: "what happens to the prompt after the call?" and "can you prove you deleted it?". Self-hosting answers the first only halfway — the content is on your infrastructure, but it is still written, and an erasure request still has to be satisfiable per record.

SOAT separates content from skeleton. Content is what a person said and what the model answered: the trace steps, a generation's metadata, error and extraction. Skeleton is everything a ledger needs: ids, timestamps, status, stop_reason, token counts, cost, and every usage-attribution field. Every mechanism in this tutorial destroys — or never writes — the first while preserving the second, so an erasure never costs you a billing record.

You will:

  1. Run a turn and confirm the content really is stored.
  2. Purge one trace on request and watch the bytes leave storage while the row survives as a provable skeleton.
  3. Purge a single generation's content, and learn why that is not enough on its own.
  4. Prove the usage and cost ledger is untouched by a purge.
  5. Automate it with a project retention window and a daily sweep.
  6. Turn on zero-retention for one agent — content is never written in the first place.
  7. Make it a project-wide mandate and watch an agent fail to opt back out.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • Ollama running locally with qwen2.5:0.5b available. This tutorial uses a local provider so it runs without external credentials — to connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.
  • New to SOAT? Read Key Concepts to understand projects, agents, and sessions first.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • For production hardening (secrets, env vars), see Configuration.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Retention and zero-retention are project settings, and changing a project requires the admin role. See Users for authentication details.

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

Step 2 — Create a project, provider, and agent

Retention is scoped to the project, so the project is the boundary every policy in this tutorial applies to.

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"

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

Send a message a compliance officer would care about, then read the trace back. file_id is the pointer to the stored steps object; content_redacted_at is null while the content is intact.

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 --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 — download it and the account number is right there:

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'

Expected output (ids will differ):

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

Step 4 — Purge the trace on request

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

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

Expected output:

{
"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, and each is deliberate:

  • The row survived as a skeleton. A 404 would prove nothing — it is indistinguishable from a resource that never existed. A purged trace still reads back, carrying content_redacted_at, so the erasure is provable to an auditor.
  • The bytes are gone, not orphaned. The steps object was deleted from storage, and the File row with it. Fetching it now fails:
# → expect-fail
soat download-file-base64 --file-id "$TRACE_FILE_ID"

Purging again is a no-op — the operation is idempotent and keeps the original timestamp:

soat purge-trace-content --trace-id "$TRACE_ID" | jq '{content_redacted_at}'
  • It cascaded. Every descendant trace was purged too, along with all of their generations. A nested agent call writes its own steps object covering the same run, so purging only the named trace would leave that content readable by another path.

Step 5 — Purge a single generation

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

soat purge-generation-content --generation-id "$GENERATION_ID" \
| 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 — that is the operation that deletes bytes from storage and cascades to every generation in the tree.


Step 6 — Confirm the ledger survived

This is the property that makes erasure adoptable: you can satisfy a deletion request without losing the record that the spend happened. The usage meter for the purged generation is intact.

soat list-usage-meters --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}'

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 on purpose. A billing and audit ledger has to outlive a tenant's erasure of the content.

cost_usd is null above only because this local model has no price rows — see Metering and Budgets to price a model and get currency figures. The purge does not touch that number either way.


Step 7 — Automate it with a retention window

A purge on request depends on someone remembering to ask. trace_content_retention_days on the project turns it into policy: a daily sweep content-purges every trace in the project older than the window.

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

What the sweep guarantees:

  • Opt-in. null is the default and disables retention entirely, so enabling the feature destroys nothing a project already stored.
  • One purge implementation. The sweep calls the same code path Step 4 did — same cascade, same storage-aware byte deletion, same content_redacted_at semantics, same audit entries and traces.content_purged events. There is no second implementation to drift.
  • A run is purged as a unit. The sweep selects root traces; when a root crosses the window, its whole subtree goes with it, including children written minutes later.
  • Auditable, not anonymous. Sweep-driven purges are stamped content_redacted_by_principal_type: "system" and content_redacted_by_principal_id: "retention_sweep", so an automated erasure is distinguishable from a requested one.

The sweep's schedule is server configuration, not a project field — see Traces — Configuration for CONTENT_RETENTION_SWEEP_INTERVAL_MS and CONTENT_RETENTION_SWEEP_DISABLED.

Clear the window with null to go back to keeping content until it is purged on demand:

soat update-project --project-id "$PROJECT_ID" \
--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. For a regulated tenant, "we never stored it" is a stronger claim than "we deleted it": content that was never written cannot leak, cannot be missed by a sweep, and cannot sit in a backup.

Create a second agent for the regulated flow and opt it in with trace_content_mode: none.

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

Now run a turn through it. The reply still reaches the caller — only the durable record is a skeleton.

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

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

{
"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"
}

Reusing the purge marker means every existing reader already handles "content is unavailable here". The principal id is what distinguishes never stored (zero_retention) from stored, then erased (a user, an API key, or retention_sweep).

What is still written is the skeleton, unchanged — which means metering is unaffected:

soat list-usage-meters --generation-id "$INTAKE_GENERATION_ID" \
| 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 is itself content, so it is not persisted in this mode. A generation still pauses and resumes normally within a running server, but a generation paused when the server restarts cannot be recovered. If restart-recovery matters more than never-stored, use a retention window instead. :::


Step 9 — Make it a project-wide mandate

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

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 it cannot loosen back to full.

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

Without that rule, a project-wide mandate could be escaped simply by creating a new agent under it. An agent's null (the default) inherits the project, and resolution fails closed: an unrecognised stored mode resolves to none rather than to permission to write content.


What you built

NeedMechanismWhere it lives
"Delete this person's conversation"purge-trace-contentOn demand, per run, cascades to descendants
"Delete this one generation's payload"purge-generation-contentOn demand, per generation
"Nothing older than N days"trace_content_retention_daysProject, daily sweep
"Never store it at all"trace_content_mode: noneProject (floor) or agent (tighten only)
"But keep the invoice"Skeleton preserved by all fourIds, status, usage, cost, attribution

Read next: Traces — Content Purge, Retention Policy, Zero-Retention, Generations — Content Purge, Projects, and Audit Log for the entries every purge writes.