Skip to main content

Literals (Pipeline)

About 5 min

Literals (Pipeline)

Pipelines allow you to wire outputs from earlier ops into the inputs of later ones without hardcoding values. You do this by placing string literals inside any args field. At runtime, the TaskManager interpolates those literals against its global store and returns a new Operation<T> with args fully substituted. Only args are modified; type, id, and results are preserved.

A literal is any substring wrapped in double percent markers that points to a value in the TaskManager store. The two most common forms are:

  • %%ops.<opId>.results.<key>%% → fetches a key from the results map of another op
  • %%ops.<opId>.host%% → resolves the runtime hostname of another op

Default Template Literals

The following table shows the available default template literals:

CategoryLiteralDescription
Globals%%globals.job%%Job ID
%%globals.host%%Host ID
%%globals.project%%Project ID
%%globals.frps_address%%FRPS address
Ops%%ops.OP_ID.host%%Op container hostname
%%ops.OP_ID.deployment_endpoint%%Op deployment endpoint
%%ops.OP_ID.endpoint.PORT_NUMBER%%Op endpoint for specific port
%%ops.OP_ID.results.RESULT_KEY%%Op results
Operators__spread__Spreads results arrays or objects across existing values
__remove-if-empty__Remove property if array is empty
__pairs__Converts an array of {key, value} objects into direct object properties

Interpolation happens when an operation is hydrated, just before it is scheduled to run. Unresolved literals throw errors.

This is the key rule: a literal is only guaranteed to resolve if the producing op is in the same group and is listed in depends_on or has run and completed successfully.

Results, Resources, and Dynamic Interpolation

Ops don't have to be static. With results and literals, you can turn one op's output into another's input, dynamically shaping images, commands, resources, or environment variables.

Results Extraction

An op can define a results block that tells the TaskManager how to extract structured values from its logs. You give each result a key, and either a regex string or an object describing a regex plus log types to scan.

For example:

{
  "type": "container/run",
  "id": "hello-world",
  "args": {
    "cmd": [
      "-c",
      "echo running ubuntu;",
      "echo Device 0: NVIDIA GeForce RTX 3070, compute capability 8.6, VMM: yes >>/dev/stderr;",
      "echo Device 1: NVIDIA GeForce RTX 3080, compute capability 8.6, VMM: yes"
    ],
    "entrypoint": ["sh"],
    "image": "ubuntu"
  },
  "results": {
    "detected-gpu-stderr": {
      "regex": "Device [0-9].*",
      "logType": ["stderr"]
    },
    "detected-gpu-all-std": "Device [0-9].*",
    "empty-result": "Missing",
    "throw-error": {
      "regex": "\\",
      "logType": ["stdout", "stderr"]
    }
  }
}

Here, GPU device strings are captured as results. Later ops can reference them via literals like:

"cmd": ["--gpus", "%%ops.hello-world.results.detected-gpu-stderr%%"]

Resources

Ops can mount resources such as S3 buckets, volumes, or secrets. Static example:

{
  "type": "container/run",
  "id": "ollama",
  "args": {
    "cmd": [],
    "image": "docker.io/ollama/ollama",
    "gpu": true,
    "expose": 11434,
    "resources": [
      {
        "type": "S3",
        "url": "s3://nos-ai-models-qllsn32u/ollama/llama3.1/70b",
        "target": "/root/.ollama/models"
      }
    ]
  }
}

But resources don't have to be hardcoded. You can dynamically build the url or target fields using literals from prior ops.

Example:

"resources": [
  {
    "type": "S3",
    "url": "s3://nos-ai-models/%%ops.model-finder.results.bucket_path%%",
    "target": "/root/.ollama/models"
  }
]

This lets you fetch models, weights, or configs discovered earlier in the pipeline.

Dynamic Images and Commands

The same literal system applies to container images and commands. You can resolve which image to run, or which CLI args to pass, from earlier results.

Dynamic image:

"image": "%%ops.resolver.results.image%%"

Dynamic command:

"cmd": ["run", "%%ops.seed.results.task_id%%"]

Since interpolation happens at hydration time, the op won't start until all its dependencies are satisfied and its literals can be resolved.

Putting It All Together

Here's a pipeline that turns a generated story into a cover image and a short video, then ships everything to S3.

It starts with the story stage. This stage has a single op that generates the story. When it finishes, it writes three results: a title, a body, and a slug. Because groups are stages and only one stage runs at a time, the next stage doesn't begin until the story stage is complete.

The media stage comes next and runs two ops in parallel: one creates a cover image, the other renders a short video. Neither declares a dependency on the story op because dependencies are only allowed within the same group. They don't need to anyway. The story stage has already finished, so both media ops can safely pull %%ops.story.results.title%% and %%ops.story.results.body%% as literals at hydration time.

Once both media ops complete, the download stage runs. It has one op that mounts your target S3 path (for example s3://my-bucket/stories/%%ops.story.results.slug%%) and uploads three things: the story payload (built from the title and body), the cover image produced by the image op, and the video produced by the video op. All three are wired by literals reading each producer's results.

That's the flow: one stage per group, strict stage ordering, parallelism inside the media stage, and all cross-stage data passed by results and resolved with literals. If you need to retry a flaky render, restart the specific media op. If you need a clean re-run of media, restart the media group. If you stop a media op, its producers are unaffected, and the download stage won't begin until the media stage is fully finished.

{
  "version": "0.1",
  "type": "container",
  "meta": { "trigger": "cli" },
  "ops": [
    {
      "type": "container/run",
      "id": "story",
      "args": {
        "image": "docker.io/ai-tools/story-gen:latest",
        "cmd": [
          "--length", "short",
          "--theme", "fantasy"
        ]
      },
      "results": {
        "title": "title=.*",
        "body": "body=.*",
        "slug": "slug=.*"
      },
      "execution": { "group": "story" }
    },

    {
      "type": "container/run",
      "id": "image",
      "args": {
        "image": "docker.io/ai-tools/image-gen:latest",
        "cmd": [
          "--prompt",
          "Cover image for %%ops.story.results.title%%"
        ]
      },
      "results": {
        "cover_path": ".*\\.png"
      },
      "execution": { "group": "media", "depends_on": ["story"] }
    },

    {
      "type": "container/run",
      "id": "video",
      "args": {
        "image": "docker.io/ai-tools/video-gen:latest",
        "cmd": [
          "--script",
          "%%ops.story.results.body%%",
          "--title",
          "%%ops.story.results.title%%"
        ]
      },
      "results": {
        "video_path": ".*\\.mp4"
      },
      "execution": { "group": "media", "depends_on": ["story"] }
    },

    {
      "type": "container/run",
      "id": "download",
      "args": {
        "image": "docker.io/library/alpine",
        "entrypoint": ["sh"],
        "cmd": [
          "-c",
          "echo Uploading story=%%ops.story.results.title%%;",
          "echo Uploading cover=%%ops.image.results.cover_path%%;",
          "echo Uploading video=%%ops.video.results.video_path%%"
        ],
        "resources": [
          {
            "type": "S3",
            "url": "s3://my-bucket/stories/%%ops.story.results.slug%%",
            "target": "/upload"
          }
        ]
      },
      "results": {
        "dest": "uploaded=.*"
      },
      "execution": { "group": "download", "depends_on": ["image", "video"] }
    }
  ]
}

Collection Operators

Collection operators are special keys that appear inside JSON configs.

They are transformed by the TaskManager before literal interpolation (%%…%%).

Each operator follows the same rules:

  • It has a reserved key name (e.g. __spread__).
  • It may define both an array handler and an object handler.
  • During the transformCollections pass, the operator is replaced by regular JSON values.
  • After transformation, no operator keys remain — only normal JSON objects and arrays.

This makes collections extensible: new operators can be added without changing how pipelines are defined.

__spread__

Expands the contents of an array or object inline.

  • Array mode → splices array items into the parent array.
  • Object mode → merges object fields into the parent object.

Before (array mode)

"expose": [
  5000,
  { "__spread__": "[7000,8000,9000]" },
  6000
]

After

"expose": [5000, 7000, 8000, 9000, 6000]

Before (object mode)

{
  "env": {
    "STATIC_KEY": "static_value",
    "__spread__": "{\"IMAGE_NAME\":\"ubuntu\",\"IMAGE_TAG\":\"20.04\"}"
  }
}

After

{
  "env": {
    "STATIC_KEY": "static_value",
    "IMAGE_NAME": "ubuntu",
    "IMAGE_TAG": "20.04"
  }
}

__pairs__

Converts an array of { key, value } objects into direct fields on the parent object.

This operator takes an array where each item is an object with key and value properties, and transforms it into a flat object where each key becomes a property name and each value becomes the property value.

Key behaviors:

  • Each array item must be an object (not a primitive or array)
  • Each object must have a key property that is a string
  • Each object should have a value property (defaults to empty string if missing)
  • The key becomes the property name in the resulting object
  • The value becomes the property value in the resulting object

Before

{
  "env": {
    "STATIC_KEY": "static_value",
    "__pairs__": "[{\"key\":\"IMAGE_NAME\",\"value\":\"ubuntu\"},{\"key\":\"IMAGE_TAG\",\"value\":\"20.04\"}]"
  }
}

After

{
  "env": {
    "STATIC_KEY": "static_value",
    "IMAGE_NAME": "ubuntu",
    "IMAGE_TAG": "20.04"
  }
}

__remove-if-empty__

Removes a property from the parent object if the associated array is empty.

This operator is useful for conditionally including properties based on whether arrays contain values, helping to keep your JSON clean and avoid passing empty arrays to services that might not handle them well.

Key behaviors:

  • Only works with array values
  • If the array is empty ([]), the entire property is removed from the parent object
  • If the array has items, the property remains with the array value
  • Non-array values are left unchanged

Before (with empty array)

{
  "args": {
    "required_param": "value",
    "optional_list": {
      "__remove-if-empty__": "[]"
    }
  }
}

After

{
  "args": {
    "required_param": "value"
  }
}

Before (with non-empty array)

{
  "args": {
    "required_param": "value",
    "optional_list": {
      "__remove-if-empty__": "[\"item1\", \"item2\"]"
    }
  }
}

After

{
  "args": {
    "required_param": "value",
    "optional_list": ["item1", "item2"]
  }
}

Adding More Operators

Because operators are just entries in DefaultCollectionMarkers, you can define additional ones.

For example, you could add __concat__, __map__, or __switch__.

Each new operator should come with:

  • A short description of its purpose.
  • Rules for how its array and object handlers behave.
  • A before/after JSON example.

Array-Aware Interpolation

Interpolation now supports arrays and structured values, not just primitive strings.

The TaskManager automatically normalizes the resolved value based on the shape of the original field in your op definition.

How It Works

  • If the original field is a string → arrays are flattened and joined into a single space-separated string.
  • If the original field is an array → arrays are flattened into the parent array.
  • If the original field is an object → each field is normalized recursively.
  • If the original field is a primitive → the resolved value is passed through as-is.

This means you don't have to hand-convert arrays to strings. The system "does the right thing" based on context.

Examples

Interpolating into an array

Before

{
  "cmd": ["%%ops.result.arr%%", 2, 1]
}

Given

{
  "ops": {
    "result": {
      "results": {
        "arr": [4, 3]
      }
    }
  }
}

After

{
  "cmd": [4, 3, 2, 1]
}

Interpolating into a string

Before

{
  "cmd": "%%ops.result.arr%%"
}

Given

{
  "ops": {
    "result": {
      "results": {
        "arr": [4, 3]
      }
    }
  }
}

After

{
  "cmd": "4 3"
}
Last update: