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.keynames what the keys are, for the reader.
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 withguard_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:
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:
{
"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."
}
}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
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 recordDeprecate 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.