Skip to content
Workflows

Assets

Reference data every item in a batch shares, published once, pinned by each batch, and kept out of your requests.

Workflow docs

Why assets exist

Most prompts carry reference data that is the same for every item: a style guide, a list of categories, a field catalog for each product type. Putting it in every item's input repeats the same bytes thousands of times. Assets hold it once. You publish them as a bundle, each batch pins a version, and every step that references an asset gets its value rendered into the prompt.

Because an asset is identical across a batch, it is also the part of a prompt a model server can reuse from one item to the next. Put assets at the start of a prompt and per-item data after them, and every item shares the same leading text. The compiler checks that order for you (see Put assets first).

Declare and reference them

A workflow lists the assets it uses under assets. There are two kinds:

  • Unkeyed, written {}: one value shared by every item, referenced as ${assets.style_guide}. This is the common case.
  • Keyed, written { key: category }: one value per key, referenced with an index, ${assets.playbook[nodes.classify.output.category]}. The index is any input or step-output path that produces the key. key names what the keys are, for the reader.
assets-playbook.yaml
name: answer-ticket
description: Classify a ticket, then draft a reply from the team's playbook for that category.
asset_bundle: support-kb

input:
  schema:
    type: object
    required: [ticket]
    properties:
      ticket: { type: string }

assets:
  style_guide: {}
  playbook: { key: category, on_missing: skip }

schemas:
  triage:
    type: object
    required: [category]
    properties:
      category: { type: string, enum: [billing, bug, account, other] }

nodes:
  classify:
    target: { product: otium-small }
    estimate: { tokens_in: 400, tokens_out: 20 }
    body:
      messages:
        - role: system
          content: "Classify the support ticket. Reply as JSON."
        - role: user
          content: "${input.ticket}"
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/triage" } }
      on_invalid: { retry: 2 }

  draft:
    target: { product: otium-medium }
    estimate: { tokens_in: 2500, tokens_out: 300 }
    body:
      messages:
        - role: system
          content: "${assets.style_guide}"
        - role: user
          content: |
            Playbook for ${nodes.classify.output.category} tickets:
            ${assets.playbook[nodes.classify.output.category]}

            Draft a reply to this ticket:
            ${input.ticket}

Indexing is one level deep and by one path: no nesting, filtering or joins. An index on an unkeyed asset, or a keyed asset referenced without one, does not compile.

The bundle defaults to one named after the workflow. Set asset_bundle when several workflows share one, as this example does with support-kb.

When a key has no value

A keyed lookup can miss: the classifier picks account and the bundle has no account playbook. on_missing decides what happens to the step:

  • skip, the default: the step is skipped with guard_false, a normal, pruned outcome.
  • fail: the step fails.

There is no default value. An empty block silently changes the prompt, which is worse than either choice. When a miss is expected, say so with a condition on the step (see Conditions); on_missing is the backstop when nothing guards the reference.

The test file covers both a hit and a miss:

assets-playbook.test.yaml
assets:
  style_guide: "Write in plain English. Be brief. Never promise a date."
  playbook:
    billing: "Check for a duplicate charge first. Refunds go back to the original card."
    bug: "Thank them, ask for steps to reproduce, and say it has been logged."

cases:
  - name: the playbook for the ticket's category is in the prompt
    input: { ticket: "I was charged twice." }
    nodes:
      classify: { output: { category: billing } }
      draft:
        { output: "Sorry about that. We have refunded the duplicate charge." }
    expect:
      status: succeeded
      succeeded: [classify, draft]
      prompts:
        draft:
          contains:
            ["Never promise a date.", "Check for a duplicate charge first."]
          not_contains: ["steps to reproduce"]

  - name: a category with no playbook skips the draft
    input: { ticket: "How do I change my email address?" }
    nodes:
      classify: { output: { category: account } }
    expect:
      status: succeeded
      succeeded: [classify]
      skipped: { draft: guard_false }

A test file's assets is the bundle its cases render against, in the same shape you publish.

Put assets first

The compiler warns when an asset comes after per-item data, or after a section that only some items include, because the shared text then sits behind text that varies and none of it could be reused. Text that stays the same for every item is fine on either side. So is a label naming the asset's own key, such as Playbook for ${nodes.classify.output.category} tickets: in the example, since the asset already varies by exactly that value.

Publish a bundle

A bundle is a JSON or YAML object. Each entry is a string, for an unkeyed asset, or an object of key to string, for a keyed one:

kb.json
{
  "style_guide": "Write in plain English. Be brief. Never promise a date.",
  "playbook": {
    "billing": "Check for a duplicate charge first. Refunds go back to the original card.",
    "bug": "Thank them, ask for steps to reproduce, and say it has been logged."
  }
}
publish
otiumctl assets publish --bundle support-kb --comment "nightly $(date +%F)" kb.json
# published support-kb@4 (2 assets, 2 keys, 248 B)
# run it again with the same file:
# unchanged: support-kb@4 (2 assets, 2 keys, 248 B)

Or POST /v1/assets with bundle, an optional comment, and the same object as assets. Each publish with new content creates the next version. Publishing content identical to the latest version creates nothing and returns that version, so a nightly job can publish from its source of truth every time.

Store values already rendered, the way the prompt needs them. If a prompt wants each type's platforms as a sentence, publish the sentence, not the list of ids it was built from: there is no step that loops or looks things up at run time, and the rendered block is also the larger shared prefix.

A bundle is reference data, not a database. It is at most 8 MiB, with a cap on the number of keys. If what you need differs for every item, it belongs in the item's input, or in a tool or external step (see Tools and external steps).

How a batch pins a version

When you submit a batch, it pins the bundle's latest published version, and every run in it renders from that version until it finishes. Publishing a new version mid-batch changes nothing for runs already submitted, and a continuation of a run keeps the version the run started with.

A batch is refused with missing_assets when the bundle does not exist or does not supply every asset the workflow declares, naming what is missing. The alternative would be a batch that runs to the end with those steps quietly skipped.

Deprecate, purge, and how long assets are kept

managing versions
otiumctl assets list                      # every bundle and its latest version
otiumctl assets versions support-kb       # one bundle's versions, newest first
otiumctl assets get support-kb 4          # a version's metadata and content
otiumctl assets deprecate support-kb 4    # new batches stop pinning it
otiumctl assets purge support-kb 4        # destroy its content, keep the record

Deprecate stops new batches pinning a version; batches that already pinned it keep it. Deprecating the latest version sends new batches back to the version before it, which is how to roll back a bad publish. The content stays.

Purge destroys a version's content and keeps its record, so runs that used it still say which version they rendered from. It is refused while a running batch pins the version.

Assets are kept differently from the rest of your data. Inputs and results are deleted 30 days after a run finishes; an asset bundle is something you publish on purpose to be reused, so a version is kept, encrypted under your account's key, until you purge it. That is what lets a past run be traced to the exact data it used.

Over HTTP, the same operations are GET /v1/assets, GET /v1/assets/{bundle}, GET /v1/assets/{bundle}/{version}, DELETE /v1/assets/{bundle}/{version} to deprecate, and POST /v1/assets/{bundle}/{version}/purge.

Stop paying for speed you don’t need.

Otium is in closed alpha. Leave your email and we’ll send an invite when a spot opens. Then point your OpenAI-compatible client at our base URL and send your first batch.

No credit card, no spam — one email when your invite is ready.

Closed alpha — onboarding is gated while we calibrate. Already invited? Sign in.