Generations
Generation records track individual LLM generation runs started by agents, including their lifecycle status and any failure details.
Overview
Every agent generation (POST /agents/:id/generate, session generation, sub-agent calls) creates a generation record before the model is called. The record tracks the run through its lifecycle and, when the run fails, stores a structured error payload so failed generations are distinguishable from pending ones.
List with GET /generations (filter by agent_id, trace_id, or status); read one with GET /generations/:generation_id.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Debug Session, Generation, and Trace History - Step 3 (Run two generations)
- Data Retention and Zero-Retention - Step 5 (Purge a single generation)
- Agent Versioning and Canary Rollout - Step 6 (Read which version served a generation)
Data Model
| Field | Type | Description |
|---|---|---|
id | string | Public identifier for the generation |
project_id | string | Project the generation belongs to |
agent_id | string | Agent that ran the generation |
trace_id | string | Trace this generation belongs to |
initiator_generation_id | string | null | Generation that started this one (see Initiated generations); null for top-level generations |
chain_id | string | null | Continuation chain this generation belongs to — set on every member including the root; null when it is not part of one |
session_id | string | null | Session this generation was dispatched through; null when it was started outside one |
actor_id | string | null | End-user actor the generation is attributed to, derived from the session; null when there is none |
started_by_principal_type | string | null | Principal kind that started the generation — user or api_key (see Starting principal) |
started_by_principal_id | string | null | Public id of that principal — the key's own key_… when a key was used, else user_… |
status | string | Lifecycle status: in_progress, requires_action, completed, or failed |
started_at | string | When the generation started |
completed_at | string | null | When the generation reached a terminal state |
last_activity_at | string | null | Last activity timestamp |
stop_reason | string | null | Why the generation stopped — see Stop Reason |
error | object | null | Structured error payload recorded when the generation failed (see Error Recording) |
metadata | object | null | Caller-owned annotations (see Metadata and Tags and metadata) |
action_id | string | null | Logical action label supplied on the generate request |
trigger_id | string | null | Trigger that initiated the generation |
orchestration_run_id | string | null | Orchestration run that dispatched the generation |
node_id | string | null | Node within that run |
node_attempt | number | null | The node's 1-based retry attempt, so a retried node's generations are told apart (see Finding an orchestration run's generations) |
agent_version | number | null | Agent config version that served the generation |
source | string | null | eval when an eval run produced this generation; null for ordinary traffic |
idempotency_key | string | null | Key the generation was started under, unique within the project (see Running a generation at most once) |
routing | object | null | What the model route did for this generation |
retrieval | array | null | What knowledge_config retrieval injected — document, version and chunk, or memory — for this turn (see What retrieval served) |
extraction | object | null | What each memory rule wrote for this turn, keyed by rule id (see extraction) |
content_redacted_at | string | null | When the generation's content was purged; null while content is intact |
content_redacted_by_principal_type | string | null | Principal kind that purged the content (user or api_key) |
content_redacted_by_principal_id | string | null | Public ID of that principal — the key's own id for API-key auth |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-update timestamp |
Key Concepts
Starting principal
started_by_principal_type / started_by_principal_id record who started the generation.
An API-key request records the key itself (key_…), so the generation names which key
acted rather than only the owning user; a JWT-authenticated request records the user
(user_…).
The pair is durable identity: work that resumes after the original request is gone
re-mints a short-lived credential from it. That lets an
approval continuation, possibly days later,
authenticate its builtin tools as the principal that started the chain, and it is why a
generation started by a request-less drive (a workflow dispatch, an
orchestration node) records the
drive's principal.
Both fields are null when the chain has no re-mintable principal: a generation started
by a trigger or an OAuth token. Each carries its authority
in the token (the trigger's attached policy, the consented scope), so recording a
principal would let a later re-mint act with the whole of the owning user's access.
Lifecycle
A generation starts as in_progress. It transitions to:
requires_actionwhen a client tool call pauses the run and the caller must submit tool outputs.completedwhen the model finishes (thestop_reasoncarries the finish reason).failedwhen the run errors, for example when the upstream AI provider returns an error or is unreachable.stop_reasonis set toerrorand theerrorfield carries the failure details.
Error Recording
When a generation fails, the failure is persisted on both the generation record and its trace: status becomes failed, stop_reason is error, and error carries { code, message }.
error always contains message. code is set for mapped errors, most notably AI_PROVIDER_ERROR: the upstream AI provider returned an error (e.g. exhausted credits, rate limit) or is unreachable.
Provider Error Surfacing (AI_PROVIDER_ERROR)
Generation endpoints return HTTP 502 with the AI_PROVIDER_ERROR code when the upstream AI provider fails:
{
"error": {
"code": "AI_PROVIDER_ERROR",
"message": "Provider returned 402: insufficient credits",
"meta": {
"provider_status_code": 402,
"generation_id": "gen_abc123",
"trace_id": "trace_xyz789"
}
}
}
meta carries the generation_id and trace_id of the failed run for inspection via GET /generations/:generation_id and GET /traces/:trace_id.
Metadata
metadata is a caller-owned bag, returned verbatim, for per-run audit attribution (e.g. the ticket or case an AI action belongs to). What knowledge the turn read is not the caller's to record: the server writes it to retrieval.
- At create time — pass a
metadataobject onPOST /agents/:id/generate. - After creation —
PATCH /generations/:generation_idwith ametadataobject. Keys are shallow-merged over the existing metadata, so repeated patches accumulate.
PATCH requires generations:UpdateGeneration; the create path requires agents:CreateAgentGeneration.
No key is reserved. Every piece of server-owned state (action_id, trigger_id, orchestration_run_id, node_id, agent_version, routing, extraction, retrieval) is a field of its own, so nothing written into metadata can reach it; a caller key spelled action_id is just an annotation.
Internal recovery state (used to resume a requires_action generation after a server restart) is stored in its own column and is never exposed through the API.
extraction — memory-rule summary
Every memory rule bound to agents.generation.completed that fired for this turn records what it wrote, keyed by the rule's id:
{ "mrule_V1StGXR8Z5jdHi6B": { "candidates": 3, "created": 2, "superseded": 1, "skipped": 0 } }
A store can have several rules, so one flat pair of counts could not say which produced them. Absent when no rule fired. Rules bound to conversations.message.generated are visible only in GET /api/v1/generations/{generation_id}/memory-assertions, which lists the rows behind every count either way.
Recorded input
A generation also stores the messages it was asked to answer, resolved (file and document
references inlined) but without the agent's own instructions or knowledge injections,
which are config recoverable from agent_version.
The record is served by the transcript, not the generation response, and
exists so a real turn can be promoted into an evaluation fixture with
create-dataset-item-from-generation.
It is content, not skeleton: never written under zero-retention, cleared by a purge,
swept by retention. A generation whose input is gone can no longer be curated and answers
409 GENERATION_CONTENT_UNAVAILABLE.
What retrieval served
An agent with a knowledge_config retrieves before the model is called, and retrieval records what that injected, in injection order:
"retrieval": [
{
"source_type": "document",
"document_id": "doc_V1StGXR8Z5jdHi6B",
"document_version": 3,
"chunk_id": "dchunk_V1StGXR8Z5jdHi6B",
"page": null,
"similarity_score": 0.82
},
{
"source_type": "memory",
"memory_store_id": "mstore_V1StGXR8Z5jdHi6B",
"memory_id": "mem_V1StGXR8Z5jdHi6B",
"similarity_score": 0.74
}
]
Together with agent_version, it answers "which playbook version produced this action". document_version is the durable pointer: a document edited between two turns leaves each citing the version it read, and the document's versions keep that text. chunk_id stops resolving once the document is re-chunked. The array order is the rank. similarity_score is raw cosine, the number that keeps its meaning once stored; null when the turn had no query or the search answered from the lexical channel alone.
null means no retrieval ran — no knowledge_config, or neither a query nor a filter to search with. [] means it ran and matched nothing.
It is written by the server when the turn starts and cannot be set by a caller. Being pointers and no text, it is not content: it survives a purge and is written even on a zero-retention agent.
Transcript
GET /generations/{generation_id}/transcript reads one turn back step by step: what it
was asked, each model step with its tool calls and results, and how it ended.
- CLI
- SDK
- curl
soat get-generation-transcript --generation-id gen_abc
const { data, error } = await soat.generations.getGenerationTranscript({
path: { generation_id: 'gen_abc' },
});
if (error) throw new Error(JSON.stringify(error));
curl https://api.example.com/api/v1/generations/gen_abc/transcript \
-H "Authorization: Bearer <token>"
The transcript is assembled at read time from the generation record and the trace's steps object; there is no transcript table and no extra write on the generation path, so it always reflects the current records.
Requires traces:GetTrace in addition to generations:GetGeneration, since the response
merges content from both resources.
Each entry in steps carries index, text, finish_reason, tool_calls,
tool_results and usage. args on a call and result on a result are tool-owned
payloads, passed through exactly as recorded.
The stored steps are projected, never forwarded; their on-disk shape belongs to the
ai package.
Two states return 200 with a skeleton rather than an error, so a caller never has to
distinguish "no content" from "no such generation":
| State | status | input / output | steps | content_redacted_at |
|---|---|---|---|---|
| Still running | in_progress | null | [] | null |
| Never stored (zero-retention) | terminal | null | [] | set, principal zero_retention |
| Erased by a purge or sweep | terminal | null | [] | set, purging principal |
step_count survives all three, being a counter rather than content. It counts this
turn's steps: when a trace_id groups several generations, the trace's own step_count
covers every one of them, while each transcript reports only its own slice — see
Traces → Grouping Generations Under One Trace.
A purged generation returns the skeleton even though the trace's steps object may still exist (see the warning under Content Purge); the redaction marker governs the whole transcript.
What the turn cost, and what it cost to ask
Two different figures, deliberately kept apart.
usage is what the provider reported and was billed for — token counts and cost_usd, in the shape every usage surface uses. Present on the single-generation read, omitted from the listing (it is a query per row), and null until the turn is metered.
tool_surface is what SOAT sent: how many tool definitions the turn carried and how large they were.
| Field | Meaning |
|---|---|
tools | Tools resolved for the turn, before any per-step narrowing by step_rules |
bytes | Serialized length of the definitions — name, description, input schema — in canonical JSON, not the provider's wire format, so two agents are comparable |
estimated_tokens | bytes over a measured bytes-per-token ratio |
It answers the one question the meter cannot. A provider reports totals, and the tool block is a constant inside them — byte-identical on every step of a turn — so it cannot be recovered by differencing steps either. An agent bound to a large MCP catalogue can spend most of its prompt describing tools it never calls, and without this nothing says so.
tool_surface is not a meterestimated_tokens is an estimate and is never priced. usage and the usage events are the billing record.
null means the surface was never measured — a generation from before the field, or a path that resolves none. That is not {"tools": 0}, which is a measured agent with nothing bound. Reading the two as one would make every older row look tool-less.
Being three integers and no text, tool_surface is not content: it survives a purge and is written even on a zero-retention agent.
Content Purge
DELETE /generations/{generation_id}/content clears the generation's content — metadata, error, extraction, the recorded input messages, and the internal recovery state of a paused run — and stamps content_redacted_at. It requires the generations:PurgeGenerationContent action.
The usage and audit skeleton is preserved (the billing ledger outlives the erasure): ids, timestamps, status, stop reason, and every attribution field (action_id, trigger_id, orchestration_run_id, node_id, node_attempt, agent_version, routing, retrieval). A purged generation reads back as that skeleton, not a 404.
The operation is idempotent: a second purge succeeds and leaves the original content_redacted_at untouched.
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's content completely, purge the trace — DELETE /traces/{trace_id}/content deletes the steps bytes from storage and cascades the content purge to every generation in the tree. See Traces.
Automatic content lifecycle
Two project settings turn the manual purge into a policy:
- Retention —
trace_content_retention_dayson the project runs a daily sweep that purges content past the window, through this same purge path. - Zero-retention —
trace_content_mode: "none"on the project or the agent means the content columns are never written. The generation is still created and metered; it reads back as a skeleton stampedcontent_redacted_by_principal_id: "zero_retention"from the moment it exists.
Initiated generations
initiator_generation_id names the generation that started this one; top-level generations leave it null. It is set on:
- a sub-agent invocation — an agent calling another agent via a builtin tool;
- an approval continuation — the turn that resumes after an approval settles, in a session or standalone;
- a client-tool re-handoff — the generation an approved client tool call is handed back to the caller on;
- a memory-rule handler turn — the agent a memory rule runs on a finished turn.
Intermediate steps of multi-step reasoning composed by the calling application are ordinary generations of their own, not metadata on or children of the calling generation.
Finding an orchestration run's generations
An orchestration run's node executions record what each node received and produced but carry no generation id. The pointer runs the other way: a generation dispatched by an agent node stores orchestration_run_id, node_id and node_attempt as attribution columns, next to action_id and trigger_id. Filter the list endpoint:
- CLI
- SDK
- curl
# every generation the run produced
soat list-generations --orchestration-run-id run_abc123
# just one node's — one row per attempt if the node was retried
soat list-generations --orchestration-run-id run_abc123 --node-id summarize
// every generation the run produced
const all = await soat.generations.listGenerations({
query: { orchestration_run_id: 'run_abc123' },
});
if (all.error) throw new Error(JSON.stringify(all.error));
// just one node's — one row per attempt if the node was retried
const node = await soat.generations.listGenerations({
query: { orchestration_run_id: 'run_abc123', node_id: 'summarize' },
});
if (node.error) throw new Error(JSON.stringify(node.error));
# every generation the run produced
curl "https://api.example.com/api/v1/generations?orchestration_run_id=run_abc123" \
-H "Authorization: Bearer <token>"
# just one node's — one row per attempt if the node was retried
curl "https://api.example.com/api/v1/generations?orchestration_run_id=run_abc123&node_id=summarize" \
-H "Authorization: Bearer <token>"
node_attempt distinguishes the generations of a retried node: one node execution record and one generation per attempt, matched exactly on node_attempt.
From a generation reached this way, the rest of the graph is reachable: trace_id opens the trace for that turn, initiator_generation_id walks down into any generations it started, chain_id opens the continuation chain it belongs to (filtering generations by that id returns every member), and session_id / actor_id name the session and end user it ran for, the same pair its usage event is attributed to.
session_id and actor_id also filter the listing, so the turns behind a conversation's or an end user's cost are one call away:
- CLI
- SDK
- curl
soat list-generations --session-id sess_abc123
soat list-generations --actor-id actor_abc123
const bySession = await soat.generations.listGenerations({
query: { session_id: 'sess_abc123' },
});
if (bySession.error) throw new Error(JSON.stringify(bySession.error));
const byActor = await soat.generations.listGenerations({
query: { actor_id: 'actor_abc123' },
});
if (byActor.error) throw new Error(JSON.stringify(byActor.error));
curl "https://api.example.com/api/v1/generations?session_id=sess_abc123" \
-H "Authorization: Bearer <token>"
curl "https://api.example.com/api/v1/generations?actor_id=actor_abc123" \
-H "Authorization: Bearer <token>"
An id naming nothing in scope yields an empty page, never an unfiltered one.
Tool context
The generation-creation endpoints (POST /agents/{agent_id}/generate, and the session and conversation generate endpoints) accept an optional tool_context object. Its entries are forwarded as X-Soat-Context-* request headers on every http, mcp and builtin tool call the generation makes; an invalid key is rejected with 400 INVALID_TOOL_CONTEXT_KEY before the provider is called. It is not persisted on the Generation record. See the Tool Context reference.
Who may act on a generation
Every route that acts on one generation — read it, attach metadata, purge its
content, or read its transcript — is authorized against that generation's
SRN, srn:<project_id>:generation:<generation_id>, not against the project. A
policy may therefore name the generations it covers:
{
"statement": [
{
"effect": "Allow",
"action": ["generations:GetGeneration", "generations:UpdateGeneration"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:generation:gen_V1StGXR8Z5jdHi6B"]
}
]
}
The transcript projects the generation and its trace, so it checks both: generations:GetGeneration against the generation and traces:GetTrace against the trace it was recorded on. A grant naming only one of them does not reach it.
Refusals keep the shapes IAM defines: a read the caller may not perform is 404 (a generation it may not see does not announce itself), a write is 403, and a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE. A write on a resource in a project none of the caller's policies name is 404 too — the same answer their read would get, so a refusal never confirms existence across a tenant boundary. The trace half of a transcript is 403 rather than 404: the generation read has already succeeded by then, so there is nothing left to conceal.
Listing generations stays project-scoped: GET /api/v1/generations asks whether the caller may list generations in a project at all, so a policy that names individual generations grants no listing.
Examples
List generations
Filter by agent_id, trace_id, initiator_generation_id, chain_id, orchestration_run_id, node_id, or status.
- CLI
- SDK
- curl
soat list-generations --trace-id trace_abc123 --status failed
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'https://api.example.com', token: 'sk_...' });
const { data, error } = await soat.generations.listGenerations({
query: { trace_id: 'trace_abc123', status: 'failed' },
});
if (error) throw new Error(JSON.stringify(error));
curl "https://api.example.com/api/v1/generations?trace_id=trace_abc123&status=failed" \
-H "Authorization: Bearer <token>"
Get a generation
- CLI
- SDK
- curl
soat get-generation --generation-id gen_abc123
const { data, error } = await soat.generations.getGeneration({
path: { generation_id: 'gen_abc123' },
});
if (error) throw new Error(JSON.stringify(error));
// data.status is "in_progress", "requires_action", "completed", or "failed"
curl https://api.example.com/api/v1/generations/gen_abc123 \
-H "Authorization: Bearer <token>"
List a generation's memory writes
- CLI
- SDK
- curl
soat list-generation-memory-assertions --generation-id gen_abc123
const { data, error } = await soat.generations.listGenerationMemoryAssertions({
path: { generation_id: 'gen_abc123' },
});
if (error) throw new Error(JSON.stringify(error));
// data.data is oldest first; data.total counts every write
curl https://api.example.com/api/v1/generations/gen_abc123/memory-assertions \
-H "Authorization: Bearer <token>"
Attach audit metadata
Merge caller-supplied metadata onto a generation for per-run audit attribution.
- CLI
- SDK
- curl
soat update-generation --generation-id gen_abc123 \
--metadata '{"team":"payments","ticket_id":"OPS-4821"}'
const { data, error } = await soat.generations.updateGeneration({
path: { generation_id: 'gen_abc123' },
body: { metadata: { team: 'payments', ticket_id: 'OPS-4821' } },
});
if (error) throw new Error(JSON.stringify(error));
// data.metadata.ticket_id === "OPS-4821"
curl -X PATCH https://api.example.com/api/v1/generations/gen_abc123 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"metadata":{"team":"payments","ticket_id":"OPS-4821"}}'