Skip to main content
keeps has a small mental model. Internalize these five ideas — assets & events, asset_id & correlation, properties, context, and the status lifecycle — and everything else in the SDK follows.

Assets and events

Every call to keeps.capture() emits one immutable event. Events are never edited or deleted; you only ever append new ones. An event is a fact about a generation at a moment in time: “this was submitted with these inputs,” “this completed with these outputs,” “this finished and the user kept it.” An asset is one generation — a single image, video, or audio clip your app produces. It is the unit you analyze: one row in the dashboard, one entry in the requests feed. You never create an asset directly. The backend folds all the events that share an asset_id into a single materialized row.
capture(submitted)  ─┐
capture(completed)  ─┼──►  backend fold  ──►  one asset row
capture(completed)  ─┘                         (inputs, outputs, status, tags)
The fold rules are deliberate and order-independent:
  • Latest non-empty wins, per field. Every field folds to the value from the latest event that actually set it, ordered by (ts, event_id) — so equal-timestamp events resolve deterministically, and an event that doesn’t carry a field can never blank out a value an earlier event set.
  • Status is terminal-sticky. Once an asset reaches a terminal status (completed, failed, or cancelled), no non-terminal event can flip it back to in-flight — a late-arriving submitted can’t reopen a completed asset. Among terminal events, the latest wins.
The SDK supports this fold by omitting any field you don’t set on a given capture() (it never sends empty strings), so a later event can add a field without clobbering one an earlier event already set.
  • Every field folds latest-non-empty per field, ordered by (ts, event_id) — the event_id tie-break makes equal-ts events resolve in emit order (the SDK’s ids are monotonic uuid7s).
  • Terminal is sticky: a late submitted or processing can’t reopen a completed/failed/cancelled asset. One attempt = one asset_id — a retry is a new asset, linked to its predecessor with the reserved reroll_of property (and numbered with attempt).
  • A completed event clears any earlier error, so a fail-then-succeed asset never shows a stale error next to a completed status. (A properties-only annotation on a failed asset leaves its error alone.)
  • Unknown statuses are non-terminal: status="succeeded" (or a typo) folds as the asset’s current status but never marks it finished — only completed, failed, and cancelled set the completion time.
  • Every asset gets a first_seen_at anchor from its earliest event — even a one-shot capture(status="completed", ...) that never sent submitted sorts and windows correctly.
When generations compete (generate four shots, keep one), record each candidate as its own asset and mark membership with a group: property key whose value is that asset’s verdict in that contest:
# each candidate is its own asset, tagged into the contest
for i in range(4):
    ids.append(keeps.capture(
        model=model, provider="fal", status="submitted",
        inputs={"prompt": prompt},
        properties={f"group:{shot_id}": "candidate"},
    ))

# later — whenever the choice happens — annotate the winner(s)
keeps.capture(ids[winner], properties={f"group:{shot_id}": "picked"})

# a video made FROM the picked image carries the lineage edge
keeps.capture(model=video_model, provider="fal", status="submitted",
             inputs={"image_url": picked_url},
             properties={"source_asset_id": ids[winner]})
Properties fold per key, so memberships are independent facts: the same asset can be picked in one contest and candidate in another, contests can have several winners (annotate several) or none (nobody picked), a head-to-head is just a 2-member group, and you can add an asset to a contest days after it was generated. This is what powers pick-rate and “why does a generation win” (trait lift among winners vs candidates) on the dashboard — and source_asset_id is what makes cross-modality funnels (“how many images before one becomes a video?”) answerable.
Because the fold is keyed on asset_id and not on arrival time, ingestion is out-of-order tolerant: a completion event can arrive before its submission (common with webhooks and forked workers) and the asset still materializes correctly. It’s also idempotent — every event carries a unique event_id, so a retried or duplicated send is deduped, never double-counted.
You don’t have to send a “submitted” then a “completed” as two separate events. A single capture(status="completed", inputs=..., outputs=...) is a complete, valid asset on its own. Split into two events only when submission and completion happen at different times or in different processes.

asset_id and correlation

The asset_id is the thread that ties an asset’s events together. The pattern is mint once, reuse to append:
import keeps

# First capture mints a uuid7 and returns it — hold on to this id.
asset_id = keeps.capture(
    model="flux-pro",
    status="submitted",
    inputs={"prompt": "a red bicycle in the rain"},
)

# ... your provider call runs ...

# Reuse the same asset_id to append the completion to the SAME asset.
keeps.capture(
    asset_id,
    status="completed",
    outputs=[{"url": "https://cdn.example.com/out.png"}],
)

# Later — a different request, maybe a different process — the user keeps it.
# Properties-only: annotations don't need to (and shouldn't) resend a status.
keeps.capture(asset_id, properties={"kept": "1"})
Passing the same asset_id back is the correlation mechanism in the SDK. Submission, completion, and any later annotation all land on one asset purely because they share that id. The asset_id is the only thing you need to carry across function boundaries, async tasks, or process boundaries.
capture() does take a provider= argument, but it is a dimension, not a correlation key — it powers pricing and per-vendor reliability breakdowns and never joins events together. The provider’s request id doesn’t correlate either: tag it as the reserved generation_id property, where it becomes the join key for reconciling against the provider’s invoice. Correlation is only the asset_id — keep it around (in your job record, your queue payload, your DB row) and pass it back.
capture() always returns the asset_id, even when the SDK is disabled or uninitialized — so your correlation logic keeps working in every environment. If you want to control the id yourself (for example, to match your own job id), pass it on the first call instead of letting keeps mint one:
asset_id = keeps.capture(asset_id=job.id, status="submitted", inputs={...})

Properties

properties are your dimensions — the tags you group and break down by. They’re stored server-side as a Map(String, String), never as schema columns. This is how you slice the data along axes keeps knows nothing about: film_id, shot_id, step, prompt_version, surface, tier.
keeps.capture(
    model="flux-pro",
    status="completed",
    outputs=[{"url": url}],
    properties={"film_id": "f_88", "shot_id": "s_12", "step": "keyframe"},
)
The fold for properties is per key, exactly like the scalar fields above: for each property key, the value from the latest event (by ts) that set that key wins. Keys you don’t set on an event are left untouched — so appending a partial bag adds to the asset’s tags rather than replacing them. Here’s the pattern made concrete. You submit with two tags, then later — once the user keeps the result — append just the new tag:
# Submission — set two tags.
asset_id = keeps.capture(
    model="flux-pro",
    status="submitted",
    inputs={"prompt": p},
    properties={"film_id": "f_88", "shot_id": "s_12"},
)

# The completion, when it happens, carries its own fields.
keeps.capture(asset_id, status="completed", outputs=[{"url": url}])

# A partial bag is fine. This merges in `kept` and leaves film_id and shot_id
# intact — the asset's properties become {film_id, shot_id, kept}. No status:
# an annotation is a properties-only event.
keeps.capture(asset_id, properties={"kept": "1"})
You don’t have to resend the full property bag on every capture() — append only the keys an event actually introduces. (Resending the whole bag is still harmless, just no longer required.) Omitting properties entirely (or passing an empty map) never overwrites anything.
To change a tag’s value, send that key again with the new value on a later event — latest ts wins. To clear a tag, send the key with a None value: it folds like any value and reads back empty. Values get one canonical string encoding server-side — send True/False and they’re stored as 'true'/'false', numbers and dicts as compact JSON.
A practical habit: keep the asset’s constant tags in one dict and let keeps.context() apply them automatically, then add per-event keys (kept, approved, …) on the calls that need them. The next section shows how.
The total property bag has a budget of 8192 bytes. An over-budget bag has its values trimmed to fit — keys are never replaced with a stub, so your group-by dimensions stay clean — and the cut is flagged in the event’s truncated field. Still: keep tags low-cardinality and small — they’re groupby keys, not a place to log payloads.

Context

keeps.context() applies ambient tags to every capture() inside a block, so you don’t have to thread the full bag through each call by hand. It tags only — it does not flush.
with keeps.context(user_id="u_42", film_id="f_88", shot_id="s_12"):
    # Both captures inherit user_id + film_id + shot_id automatically.
    a1 = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p1})
    a2 = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p2})
The user_id keyword sets the event’s user; every other keyword becomes a property tag. Resolution order for any single key is: an explicit argument on capture() wins, then the innermost context(), then the init() default. Blocks nest, and the inner block wins per key:
with keeps.context(user_id="u_42", film_id="f_88"):
    keeps.capture(model="flux-pro", status="submitted", inputs={...})
    # tags: user_id=u_42, film_id=f_88

    with keeps.context(shot_id="s_12"):
        keeps.capture(model="flux-pro", status="submitted", inputs={...})
        # tags: user_id=u_42, film_id=f_88, shot_id=s_12  (merged, inner adds)

    with keeps.context(film_id="f_99"):
        keeps.capture(model="flux-pro", status="submitted", inputs={...})
        # tags: user_id=u_42, film_id=f_99  (inner overrides film_id)
Because context tags are re-applied to every capture() in the block, they’re the cleanest way to keep an asset’s constant tags (film_id, shot_id) on each event without threading them by hand — provided each event for an asset runs inside the same context. Per-event keys that aren’t constant (kept, approved) can just be passed on the individual capture() that introduces them; the per-key fold merges them in.
keeps.context() follows the current thread/task and is copied into asyncio tasks created inside the block. It does not leak into threads you spawn yourself. And to be explicit: leaving a context block does not deliver anything — call keeps.flush() when you need to guarantee events are sent. See Reliability.

Status lifecycle

status describes where a generation is. The lifecycle is simple:
submitted  ──►  completed
           ├─►  failed
           └─►  cancelled
StatusMeaningTypical fields
submittedThe generation was kicked off.inputs, model
completedIt finished successfully.outputs (URLs/refs)
failedIt errored.error (verbatim from the provider)
cancelledIt was called off.— (the spelling canceled is normalized at ingest)
status is an open set — any string is accepted, and the SDK doesn’t validate it. But the terminal vocabulary is fixed: exactly completed, failed, and cancelled finish an asset. Any other status (queued, processing, or a typo like succeeded) folds as the asset’s current state but never marks it finished — so stick to the reserved words for the moments that matter.
asset_id = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p})

try:
    out = run_provider(p)
    keeps.capture(asset_id, status="completed", outputs=[{"url": out.url}])
except ProviderError as e:
    keeps.capture(asset_id, status="failed", error={"message": str(e)})
Two things the SDK deliberately does not do, because they’re the backend’s job:
  • Cost is computed server-side from pricing tables keyed on model. You never send a price; you don’t have to keep pricing in your app at all.
  • Errors are normalized server-side into a taxonomy. The SDK ships the provider’s error verbatim — pass the raw provider message and let the backend classify it. Don’t pre-bucket errors yourself.
This keeps the SDK thin and your data correct even as pricing and error taxonomies evolve: re-classification happens on already-recorded events, with no SDK change or redeploy.
Put URLs, asset refs, and metadata in outputs; media files stay wherever you host them. If you hand keeps bytes, only their length is recorded.

Where to go next

Recording provider calls

The submit → complete → feedback pattern around fal, Replicate, and OpenAI.

Reliability

Buffering, flushing, and why short-lived workers must call flush().

API reference

Every parameter on init, capture, context, flush, and stats.

Privacy & safety

What leaves your servers, verbatim capture, and the fail-open guarantee.