---
title: "Media Generation Tracking — Technical Design"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/media-generation-tracking-design
markdown: https://docs.nodetool.ai/media-generation-tracking-design.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/media-generation-tracking-design.md
---

# Media Generation Tracking — Technical Design

# Media Generation Tracking — Technical Design

**Status:** Draft, for review
**Owner:** Matti Georgi
**Related:** `packages/execution/src/cost-ledger.ts`, `packages/runtime/src/cost-reconciler.ts`, [tool-class-retirement-design.md](tool-class-retirement-design.md), [HARNESS_FIRST.md](HARNESS_FIRST.md)

---

## 1. Summary

A media generation request is money out and minutes of wall clock, and today
the record of it is written *after* it succeeds, by a listener that never sees
a failure, a cancellation, or a process restart. The row it writes names no
asset, the asset names no row, and the reconciliation that turns an estimate
into the billed amount is a detached promise that dies with the process.

One primitive fixes this: a **generation** is a row opened *before* the
provider is called and closed with a terminal status, a cost, and the assets it
produced. A **follower** owns that row from submit to close. It is not a new
runtime: it is the ledger listener, rewritten to track a lifecycle instead of
recording a success, plus a durable reconcile queue and a startup sweep. The
follower runs in the process that made the call and outlives the caller
(a cancelled chat turn, a finished tool call), so the outcome is recorded even
when nobody is waiting for it.

Agents get the record as a capability module, `generations`: list, get,
await, cancel, reconcile. Generation capabilities gain `background: true`,
which returns the generation id at once and leaves the follower to finish the
job. The CLI gets the same surface as `nodetool generations`.

## 2. What is wrong today

Read with `packages/execution/src/cost-ledger.ts` and
`packages/runtime/src/context.ts` open.

- **F1. Only success is recorded.** `recordFromMessage` writes a row on
  `prediction` + `status: "completed"`. A `failed` prediction, a cancelled one,
  or one interrupted by a restart writes nothing. kie bills a task at submit
  and Meshy bills a preview pass before refine, so a failure is not free.
- **F2. Cost and asset are not linked.** A `nodetool_predictions` row has no
  asset id and an `nodetool_assets` row has no prediction id. "What did this
  clip cost" and "which asset came out of this charge" are both unanswerable.
- **F3. The message id is not the row id.** `runProviderPrediction` mints a
  `randomUUID()` for the `prediction` message; `Prediction.create` mints
  another for the row. A client that watched the message cannot find the row.
- **F4. Reconciliation is fire-and-forget.** `recordNodeProviderCost` calls
  `void reconcileProviderCost(...)`. No retry, no record of whether it ran, and
  it dies with the process. kie never reconciles at all:
  `reportKieProviderCost` (`packages/kie-nodes/src/kie-base.ts`) omits
  `provider_request_id` though the task id is in hand.
- **F5. Five caller surfaces, three bypass the seam.** Workflow nodes and agent
  capabilities go through `runProviderPrediction`. The chat turn's direct
  generation (`packages/websocket/src/session/chat-turn.ts`), the
  `generate_media` RPC (`session/inference.ts`) and `nodetool generate`
  (`packages/cli/src/commands/generate.ts`) call `provider.textToImage(...)`
  and friends directly, each with its own asset save and its own
  `Prediction.create`. BYOK direct generation writes no row by design.
- **F6. 3D generation is off the books.** `TextTo3DNode` and `ImageTo3DNode`
  (`packages/video-nodes/src/nodes/model3d/generation.ts`) call
  `provider.textTo3D` directly. `text_to_3d` and `image_to_3d` are absent from
  `ProviderCapability` and from `UNIT_BILLED_CAPABILITIES`. Meshy and Rodin
  spend reads as free.
- **F7. A `completed` prediction message carries the raw output bytes as
  `data`.** Every listener, the chat socket forwarder included, receives a
  full video buffer. Nothing in `web/src` reads `data`; the chat and workflow
  reducers read `status`, `node_id` and `logs`.
- **F8. An agent cannot see a generation.** `get_cost_summary` aggregates; no
  capability lists what is running, what a call cost, or where its asset is.

## 3. Design goals

1. **One row per generation, opened before spend.** The row exists from the
   moment the provider is asked, whatever happens after.
2. **Every terminal state is a row state.** completed, failed, cancelled,
   interrupted. Silence is never "free".
3. **The asset is the outcome.** A completed generation names its assets, and
   each asset carries its generation id.
4. **One seam.** Every surface that asks a provider for media goes through
   `ProcessingContext`. An audit fails the build on a new bypass.
5. **Reconciliation is durable.** A charge that can be reconciled is
   reconciled, retried with backoff, and the row says whether it was.
6. **The follower is the capability.** Rule 4 of HARNESS_FIRST: the CLI and
   agent-tool forms are the feature, the dashboard renders them.

## 4. Architecture

```
caller (capability · node · chat turn · RPC · CLI)
  │  context.runGeneration(req)                ← the one seam (§5)
  ▼
ProcessingContext (packages/runtime/src/context.ts)
  id = randomUUID()
  emit prediction{status:"running", id, origin, params}
  dispatchCapability(provider, req)  inside a receipt scope (AsyncLocalStorage)
    provider code: recordGenerationReceipt({provider_request_id, cost?})
  persist output → createAsset(…, metadata.generation_id = id)
  emit prediction{status:"completed", id, asset_ids, receipt, duration}
       or        {status:"failed",    id, error, receipt}
       or        {status:"cancelled", id, receipt}
  return {id, output, assets}
  │  addMessageListener
  ▼
GenerationTracker (packages/execution/src/generation-tracker.ts)   ← the follower
  running   → Prediction.create({id, status:"running", …origin})
  completed → price (receipt cost wins, else catalog) · update row · enqueue reconcile
  failed    → update row · enqueue reconcile when a request id exists
  cancelled → update row · enqueue reconcile when a request id exists
  ReconcileQueue: rows with provider_request_id, reconciled_at null, attempts < 5
  StartupSweep:   rows still "running" from a dead process → "interrupted"
  │
  ▼
nodetool_predictions (packages/models)      ← one table, lifecycle in place
  ▲                          ▲
  │ generations capabilities │ nodetool generations · nodetool costs · Costs dashboard
```

The dependency arrows are unchanged: `runtime` knows nothing about `models`,
`execution` imports both, hosts attach the tracker where they attach the
ledger today (`packages/execution/src/session.ts`,
`service/workflow-run.ts`, `packages/websocket/src/session/chat-turn.ts`).

### D1. Reuse `nodetool_predictions`; do not add a generations table

The table already carries `status` (default `pending`), `error`,
`provider_request_id`, `parameters`, `metadata`, `started_at`,
`completed_at`, `duration`, and the unit-billing columns. `nodetool costs`,
`get_cost_summary` and the Costs dashboard read it. A second table would
split "what ran" from "what it cost" and every consumer would join them. The
lifecycle goes into the row that already exists; the write path changes from
insert-on-success to insert-on-start plus update-on-close.

### D2. The follower is a listener plus a durable queue, not a worker process

The provider call is already an in-process `await` inside the SDK or the
provider's own poll loop (`kie-provider.ts pollUntilDone`,
`gemini-provider.ts` operation polling, `meshy-provider.ts pollTaskStatus`).
The follower does not take that over. It watches the messages the seam emits,
which is what the ledger does today, and adds the two things a listener
cannot give: a queue that survives a restart (reconciliation, stored in the
row) and a sweep that closes rows the restart orphaned. Resumable polling of a
provider job across restarts needs a submit/poll split in every provider and is
out of scope (§12).

### D3. Receipts flow through AsyncLocalStorage, not through return types

Twenty-five provider methods return bytes. Threading a request id through
each signature is a large diff for one field. `packages/runtime/src/invocation-account.ts`
already scopes `{costUsd, createdAssets}` per invocation with
AsyncLocalStorage; the receipt scope is the same shape. A provider calls
`recordGenerationReceipt({provider_request_id, cost?})` from inside the method
and the seam reads it after dispatch. FAL (`client.subscribe` returns
`requestId`) and kie (`taskId`, `creditsConsumed`) fill it in first.

### D4. The seam persists the asset

Today `persistOutput` in `packages/agents/src/tools/asset-persist.ts` saves the
asset after the seam returns, so the seam cannot name it. `runGeneration` takes
a `persist` option and saves through `createAsset` before it emits
`completed`, stamping `metadata.generation_id`, `workflow_id`, `node_id` and
`job_id` on the asset. The caller gets `assets` back and stops saving on its
own. A host with no `createAsset` interface (a hermetic eval, the CLI without a
database) gets the bytes and writes a workspace file as before; the row then
records `asset_ids: []` and `metadata.persisted: "workspace"`.

## 5. The seam

In `packages/runtime/src/context.ts`.

```ts
export type GenerationSurface =
  | "workflow" | "capability" | "chat" | "rpc" | "cli";

/** Who asked. Every field the row and the asset carry back to the caller. */
export interface GenerationOrigin {
  surface: GenerationSurface;
  thread_id?: string | null;
  tool_call_id?: string | null;
  job_id?: string | null;
  node_id?: string | null;
}

export interface GenerationRequest extends ProviderPredictionRequest {
  origin?: Partial<GenerationOrigin>;   // defaults from the context (§5.1)
  persist?: {
    name?: string;          // asset name; default `<capability>-<ts>.<ext>`
    mime?: string;          // default sniffed from bytes, then by capability
    parentId?: string;      // asset folder
  };
  signal?: AbortSignal;     // cancel_generation reaches the provider through it
}

export interface GenerationResult<T = ProviderPredictionResult> {
  id: string;                       // the row id and the message id (F3)
  output: T;
  assets: AssetRef[];               // empty when nothing was persisted
  receipt: GenerationReceipt | null;
  duration_ms: number;
}

export interface GenerationReceipt {
  provider_request_id?: string | null;
  /** The provider's own charge, when it states one. Wins over the catalog. */
  cost?: Pick<ProviderCost, "amount" | "currency" | "billing_unit" | "quantity" | "unit_price"> | null;
}

runGeneration(req: GenerationRequest): Promise<GenerationResult>;
```

`runProviderPrediction(req)` stays and becomes
`(await this.runGeneration(req)).output`, so the node call sites keep
compiling. `textToSpeechEncoded`, `textToMusic` and `streamProviderPrediction`
get the same lifecycle (they already emit `running`/`completed`/`failed`; they
gain the origin, the receipt and the cancelled state).

### 5.1 Origin defaults

The context knows `userId`, `jobId`, `workflowId` and `threadId`. A request
that names no origin gets `surface: "workflow"` with the context's job and
workflow ids inside a run, and `surface: "chat"` with its thread id inside a
chat turn. A capability passes `surface: "capability"` and its `tool_call_id`
explicitly, because that is the one id the context does not hold.

### 5.2 Message changes

`predictionSchema` in `packages/protocol/src/messages.ts`:

- `status` narrows to `"running" | "completed" | "failed" | "cancelled"` on
  the wire; `"interrupted"` exists only in the row, written by the sweep.
- add `origin: GenerationOrigin`, `asset_ids: string[]`,
  `receipt: GenerationReceipt | null`.
- `data` stops carrying bytes (F7). It stays in the schema, nullable, for the
  segmentation result (`ImageSegmentationMask[]` metadata without pixels) and
  the ASR transcript, which are results and not media.

`params` is redacted before it is emitted or stored: byte fields, `data:` URLs
and base64 blobs are replaced with `{bytes: n}`. The redaction is the one the
supervisor uses for `Escalation.inputs`.

### 5.3 Capabilities added to the seam

`text_to_3d` and `image_to_3d` join `ProviderCapability`,
`dispatchCapability` and `UNIT_BILLED_CAPABILITIES` (F6). The two 3D nodes call
`runGeneration` instead of `context.getProvider(...).textTo3D(...)`.

### 5.4 Cancellation

`runGeneration` registers `{id, abort}` in a process-level
`GenerationRegistry` for the life of the call. The registry is the shape of
`packages/agents/src/background-subtasks.ts` (a record map, a version counter,
a waiter list), lifted to the process because a generation outlives the turn
that started it. `cancel_generation` aborts it;
the seam emits `cancelled` with whatever receipt the provider recorded before
the abort. Providers that already take `signal` (image and video params)
stop polling; the rest finish their current HTTP call and the result is
discarded. Cancelling does not refund: a provider that bills at submit still
bills, which is why a cancelled row keeps its request id and is reconciled.

## 6. The follower

`packages/execution/src/generation-tracker.ts`, replacing the `prediction`
branch of `recordFromMessage`. The `node_update` + `provider_cost` branch stays
for the node packages that call a provider SDK directly (§8, S7).

### 6.1 Row lifecycle

| Message | Row write |
|---|---|
| `running` | `Prediction.create({id, user_id, status: "running", provider, model, capability, surface, thread_id, tool_call_id, job_id, node_id, workflow_id, project_id, document_id, parameters, started_at})` |
| `completed` | `update({status: "completed", cost, billing_unit, quantity, unit_price, currency, provider_request_id, asset_ids, duration, completed_at, metadata.price_source})`, then enqueue reconcile when `provider_request_id` is set |
| `failed` | `update({status: "failed", error, provider_request_id, completed_at})`; enqueue reconcile when a request id exists, because a failed call may be billed |
| `cancelled` | as `failed`, with `status: "cancelled"` |

Pricing order on `completed`: the receipt's cost when the provider stated one
(`metadata.price_source: "provider"`), else the catalog through
`priceGeneration` (`"model-catalog"`), else `cost: null` with
`unpriced_reason`, as today. A null stays null: an unpriced row is countable,
a zero is a lie.

A create that fails (no database on this host) is logged at `warn` and the
tracker remembers the id, so the later update for that id is skipped instead
of logged again. Bookkeeping stays best-effort for a hermetic run, and loud.

### 6.2 Reconcile queue

State lives in the row: `reconciled_at`, `reconcile_attempts`,
`metadata.reconcile_error`. The queue is a query, not memory:

```sql
provider_request_id IS NOT NULL AND reconciled_at IS NULL
AND reconcile_attempts < 5 AND status IN ('completed','failed','cancelled')
```

A worker drains it on a timer (every 5 minutes; the first attempt for a fresh
row after 60 s, because billing events lag the request) with attempts spaced
1, 5, 30, 120, 720 minutes. A provider with no reconciler marks the row
`reconciled_at = now, metadata.reconcile: "unavailable"` on the first pass so
it leaves the queue. The FAL reconciler stays as it is
(`packages/fal-nodes/src/fal-billing.ts`); kie gains one over
`/api/v1/jobs/recordInfo`, whose status payload already carries
`creditsConsumed`. The worker is attached once per process by the server
(`packages/websocket`) and by the CLI when it opens a database; an eval attaches
none.

### 6.3 Startup sweep

On attach, rows with `status = "running"` and `started_at` older than the
process start are set to `interrupted` with
`metadata.interrupted_reason: "process restart"`, and enqueued for reconcile
when they carry a request id. A row a live process is still driving is never
older than its own start, so the sweep cannot close a real run.

### 6.4 Run totals

`packages/websocket/src/session/job-execution.ts` accumulates a run's cost
for the job row from the same messages (`_handlePredictionCost`,
`_handleNodeProviderCost`, `runMeasuredCost`), re-pricing each prediction
itself. It keeps doing that, from the new `receipt` field first and the
catalog second, so the job total and the row agree by construction. It does
not write rows; that stays the tracker's.

### 6.5 Double counting

One generation, one row. Two guards:

- A node that goes through the seam *and* calls `setProviderCost` (the FAL and
  kie factories, once they move to the seam in S7) would write a row from the
  `prediction` message and a second one from `node_update.provider_cost`. The
  tracker records the invocation's generation ids; a `node_update` whose
  invocation already produced a row with a receipt cost writes nothing, and a
  `node_update` whose row exists without a receipt updates that row instead
  of inserting. The kernel already scopes an invocation
  (`invocation-account.ts`), so the mapping is by invocation, not by guess.
- `runDirectMediaGeneration` (`inference.ts`) writes its own
  `Prediction.create` after `reserveSpend`. That write goes; the seam's row is
  the row, and the nodetool-provider budget path keeps `admitSpend`/
  `reserveSpend`/`releaseSpend` around the `runGeneration` call.

## 7. Schema

`packages/models/src/schema/predictions.ts` and `schema-pg/predictions.ts`,
one migration in `migrations/versions.ts`:

| Column | Type | Why |
|---|---|---|
| `capability` | text | the `ProviderCapability`; today only in `metadata` |
| `surface` | text | `GenerationSurface` |
| `thread_id` | text, index `(user_id, thread_id)` | the chat turn that asked |
| `tool_call_id` | text | the capability call that asked |
| `job_id` | text, index | the run that asked |
| `asset_ids` | json text | the outcome (F2) |
| `reconciled_at` | text | queue state (§6.2) |
| `reconcile_attempts` | integer, default 0 | queue state |

plus index `(user_id, status)` for `list_generations`. Existing rows keep
`status: "completed"` and nulls elsewhere; nothing is backfilled.

Assets: no column. `metadata.generation_id` on the asset row, written by the
seam, is enough for `get_generation` and for an asset panel to show "cost
$0.41, 6 s of Veo" on the clip. `nodetool_assets` already carries
`workflow_id`, `node_id` and `job_id`.

Aggregates (`Prediction.aggregateDashboard`, `aggregateByUser`,
`aggregateByProvider`, `aggregateByModel`, `listSpendByProject`) add
`status != 'running'` so an open row is neither summed nor counted as
unpriced, and gain `running_count` and `interrupted_count` in
`AggregateResult`. The filter excludes rather than allows because the token
writers (`chat-turn.ts _logProviderCall`, `cli/src/supervisor.ts`,
`app-build/build.ts`) all write `completed` today and none of them is touched
here. Spend sums include a failed row that carries a cost, because that is
money out.

## 8. Surfaces that must go through the seam

Enumerated, not sampled. Each one either moves in this design or is named as
staying out with the reason.

| | Surface | Today | Change |
|---|---|---|---|
| S1 | Agent capabilities: `generate_image`, `edit_image`, `segment_image`, `generate_video`, `animate_image`, `generate_speech`, `generate_music` (`packages/agents/src/capabilities/media.ts`) | `runProviderPrediction` then `persistOutput` | `runGeneration` with `persist` and `origin: {surface: "capability", tool_call_id}`; drop the capability-side save; return `generation_id` next to `asset_id` |
| S2 | `render_storyboard_stills`, `render_storyboard_clips`, `revise_storyboard_clip` (`capabilities/storyboards.ts`), `voice_script_lines` (`capabilities/scripts.ts`, which calls `generateSpeech.impl`) | same as S1 | same as S1; the shot and the take record the generation id next to the asset id |
| S3 | Workflow nodes through `runProviderPrediction` (`image-nodes`, `video-nodes`, `audio-nodes`, `video-nodes/script.ts`, `llm-nodes/shots.ts`) | seam, no persist, `autoSaveAsset` on the websocket host (`packages/websocket/src/session/asset-autosave.ts`, driven per `generation_complete`, deduped on `metadata.generation_index`) | unchanged call; origin defaults to the run. The node returns an inline ref, so the seam cannot name the asset. Linking goes the other way: the tracker keeps, per `(job_id, node_id)`, the generation ids it opened and has not linked; autosave asks for them when it saves that node's `generation_complete`, stamps `metadata.generation_ids` on the asset, and the tracker writes `asset_ids` on those rows. A node that made several generations in one invocation links all of them to the asset rather than guessing which one |
| S4 | 3D nodes (`video-nodes/src/nodes/model3d/generation.ts`) | direct `provider.textTo3D` (F6) | `runGeneration` with the two new capabilities |
| S5 | Chat turn direct generation (`chat-turn.ts handleMediaGenerationMessage`) and `generate_media` RPC (`inference.ts runDirectMediaGeneration`) | direct provider methods, own asset save, own `Prediction.create` (RPC) | `ctx.runGeneration` with `persist` and `origin: {surface: "chat" \| "rpc", thread_id}`; delete both local `storeAsset` helpers and the RPC's row write; BYOK gets a row, unpriced when the catalog has no price |
| S6 | `nodetool generate` (`packages/cli/src/commands/generate.ts`) | direct provider methods, `node:fs` write, hand-rolled `recordGenerationSpend` | build a `ProcessingContext` (the CLI already does for `node run`), `runGeneration` with `origin: {surface: "cli"}`; `-o` still writes the file; the row and the asset come from the seam when a database is open |
| S7 | Provider node packages that drive an SDK themselves: `fal-nodes`, `kie-nodes`, `atlascloud-nodes`, `together-nodes`, `topaz-nodes`, `minimax-nodes`, `replicate-nodes`, `reve-nodes`, `huggingface-nodes`, `elevenlabs-nodes`, `integration-nodes/kie-dynamic.ts` | `setProviderCost` on FAL and kie only, `autoSaveAsset` for the media, no row for the rest | **Stays on the `node_update` path in this design.** Two fixes ship with it: kie passes `taskId` as `provider_request_id` (F4) and `fal-dynamic.ts` calls `reportFalCost` like `fal-factory.ts` does. Moving these packages behind the seam means giving `dispatchCapability` an endpoint-shaped request, which is its own design. The audit (§9) lists them as an allowed set so the debt is visible, not forgotten |

## 9. Audit

`packages/execution/tests/generation-seam-audit.test.ts`, modelled on
`packages/runtime/tests/url-egress-audit.test.ts`. It scans `packages/*/src`
for a call to any media method of `BaseProvider` (`textToImage`,
`imageToVideo`, `textTo3D`, ... the list is read from `base-provider.ts` so a
new method is covered the day it lands) outside `packages/runtime/src/` and
outside the S7 allow-list, and fails on the first unclassified call. It also
asserts it found the S7 calls, so it cannot pass by matching nothing. It is
inverted once in the PR that adds it: a bare `provider.textToImage(` in a
scratch file under `packages/agents/src/` must turn it red.

## 10. Capabilities

New module `packages/agents/src/capabilities/generations.ts` +
`generations.specs.ts`. Wiring follows the checklist every module pays:
the spec import, the loader in `MODULES`, the name in
`DECLARED_CAPABILITY_MODULES` and the entry in `MODULE_SPECS` of
`capabilities/registry.ts`; the category snapshot in
`packages/agents/tests/capabilities-registry.test.ts`; the `generations`
namespace in the CodeAct API map (`codeact/nodetool-api.ts`), so guest code
gets `nodetool.generations.wait(id)` next to `nodetool.jobs.wait(id)`; the
tRPC classification in `packages/websocket/src/trpc/sandbox-coverage.ts`
for any procedure the dashboard gains to list generations (classified
`capability`, since the module covers it); the module table in
`docs/AGENTS.md`; and a `capability-table.ts` entry per capability naming
`packages/agents/tests/capabilities-generations.test.ts` and
`packages/execution/tests/generation-tracker.test.ts`.

| Name | Category | Input | Returns |
|---|---|---|---|
| `list_generations` | read | `status?`, `provider?`, `capability?`, `thread_id?`, `job_id?`, `since?` (ISO), `limit` (default 50, max 500) | `{generations: GenerationSummary[], next}`: id, status, provider, model, capability, cost, currency, asset_ids, started_at, duration, error |
| `get_generation` | read | `generation_id` | the full row: parameters (redacted), price breakdown, receipt, reconcile state, assets as `asset://` refs, origin |
| `await_generation` | read | `generation_id`, `timeout_seconds` (default 300, max 1800) | the row once terminal, or `{status: "running", waited_seconds}` on timeout. In-process it subscribes to the registry; across processes it polls the row every 5 s |
| `cancel_generation` | write | `generation_id` | `{generation_id, status: "cancelled"}` or `{cancelled: false, error}` when the id is not running or not the caller's. The category matches `cancel_job`, and so does the row write: one UPDATE with `id`, `user_id` and `status = 'running'` in the WHERE (`Job.markCancelledIfActive`), then the abort |
| `reconcile_generation` | external | `generation_id` | `{before: cost, after: cost, reconciled: boolean, reason?}`; runs the provider's reconciler now, outside the queue's schedule |

Every read is scoped: `Prediction.find` is unscoped today and stays that way
for the ledger; the module uses a new `Prediction.findForUser(userId, id)` and
`Prediction.listGenerations(userId, filter)`, both with `user_id` in the
`WHERE`.

### 10.1 `background: true` on the generation capabilities

`generate_image`, `edit_image`, `generate_video`, `animate_image`,
`generate_speech`, `generate_music`, `render_storyboard_stills` and
`render_storyboard_clips` accept `background: true`. The capability calls
`runGeneration` without awaiting the result, reserves the estimated cost
against the run's `RunBudget` (the same reservation a synchronous call makes),
and returns `{generation_id, status: "running", estimated_cost}` in the same
turn. The follower closes the row; `await_generation` collects it. A
background generation an agent never awaits is still finished, recorded, and
its asset saved, which is the whole point.

Bounds: at most 16 background generations open per run (the number the native
flow allows for streams), and none once the budget is exhausted. A run that
ends with background generations open leaves them running: they are the
follower's, not the turn's.

### 10.2 What is returned to the model

Every generation capability adds `generation_id` to its result next to
`asset_id`, so a model that wants the cost of the image it just made calls
`get_generation` with an id it already holds. `persistOutput`'s `SavedOutput`
shape is unchanged apart from that field.

## 11. Harness

- **CLI**: `nodetool generations list [--status] [--provider] [--since] [--json]`,
  `get <id>`, `await <id> [--timeout]`, `cancel <id>`, `reconcile <id>`, plus
  `nodetool generations sweep` (run the startup sweep and one queue drain by
  hand). `nodetool costs list` gains `status` and `assets` columns. Each
  subcommand takes `--json`; exit code is the verdict.
- **Selfcheck**: `packages/execution/tests/generation-tracker.test.ts` with
  the fake provider: running→completed writes one row with the asset id and
  the message id; failed writes a failed row; an aborted call writes cancelled;
  a row older than process start is swept to interrupted; a receipt cost wins
  over the catalog; a reconciler that throws increments attempts and leaves
  the row queued; five failures leave the queue. `packages/agents/tests/capabilities-generations.test.ts`
  drives the five capabilities and `background: true` through the headless
  run, and asserts a foreign user's row is invisible.
- **Registry**: surface `generation-tracking` in
  `packages/cli/src/harness/registry.ts` with paths
  `packages/execution/src/generation-tracker.ts`,
  `packages/agents/src/capabilities/generations*.ts`,
  `packages/runtime/src/generation-receipt.ts`, covered by
  `capability-suites`; the audit test is named as its selfcheck.
- **Eval**: one `creative-pipeline` case where the objective is "render these
  four shots and tell me what each cost", scored on `render_storyboard_stills`
  followed by `get_generation` or `list_generations`.

## 12. Out of scope

- **Resumable provider polling.** After a restart, a kie task or a Veo
  operation is still running at the provider. Picking it up again needs a
  `submit`/`poll` split per provider (`kie`, `gemini`, `openai`, `meshy`,
  `rodin`, `minimax`, `together`, `xai`, `atlascloud`, `evolink`, `topaz`; FAL
  and Replicate hide it inside their SDKs). The row records the request id so
  the cost is recovered; the media is not. This is the next design once the
  row exists.
- **Moving S7 behind the seam** (§8).
- **Budget refusal before the call.** `admitSpend` exists for the nodetool
  provider only; extending the pre-run gate to BYOK is a pricing question, not
  a bookkeeping one.
- **UI.** The Costs dashboard and an asset panel badge render the row; neither
  is designed here.

## 13. Rollout

| Phase | Ships | Gate |
|---|---|---|
| P1 | Schema, `runGeneration` + receipts + `persist`, tracker with queue and sweep, S1/S2 capabilities on the seam, `generations` module, CLI, audit (with S4/S5/S6 in the allow-list, marked `TODO(P2)`) | tracker and capability suites; audit inverted; `capabilities:check`; `harness gate` |
| P2 | S4, S5, S6 on the seam; S7 fixes (kie request id, fal-dynamic cost); allow-list shrinks to S7 | audit shrinks and stays green; `generate_media` route tests; CLI `generate` writes a row through the seam |
| P3 | `background: true`, `await_generation`, `cancel_generation` wired to the registry; aggregate changes and dashboard columns | capability suite for background; `costs` route tests |

P1 is the bookkeeping the title asks for. P2 closes the bypasses. P3 is the
ergonomics.

## 14. Risks

- **R1. A row for every call doubles the write rate on the predictions
  table.** Two writes per generation instead of one, on a table indexed five
  ways. A generation is seconds to minutes of provider time; two SQLite
  writes are not the bottleneck. Watched, not mitigated.
- **R2. The sweep marks a slow generation interrupted after a restart while
  the provider finishes it.** Correct: the process that would have collected
  the bytes is gone. The row says so, keeps the request id, and reconciles the
  cost. The media is lost until §12's resumable polling.
- **R3. A provider reports a request id but no reconciler exists.** The row
  leaves the queue on the first pass as `unavailable`; the estimate stands. No
  retry storm.
- **R4. Receipt scope leaks across concurrent generations.** AsyncLocalStorage
  isolates per async context; a provider that schedules work outside the
  awaited chain (a detached timer) would record into nothing. The receipt call
  logs at `debug` when it finds no scope, and the tracker test runs two
  generations concurrently and asserts each row gets its own id.
- **R5. Dropping bytes from `prediction.data` breaks a consumer.** Enumerated:
  `web/src/stores/workflowUpdates.ts`, `web/src/core/chat/chatProtocol.ts`,
  `web/src/hooks/timeline/useGenerateClip.ts`,
  `web/src/hooks/sketch/useGenerateLayer.ts`,
  `packages/websocket/src/chat-prediction-forwarder.ts`. None reads `data`.
  Mobile is checked in the PR the same way.
- **R6. `background: true` spends money a turn never reports.** The
  reservation against `RunBudget` happens at submit, so the cap holds; the
  turn's cost summary counts reserved spend as spent and says
  `n generations still running`.
