Octov0.11.7
Guides

Running Commands

Run a local program from a flow, stream its output, and hand a model a safe toolbox.

The cli-run block runs a command-line tool, watches its output a line at a time, and carries on with how it ended. Three samples build this up: cli-run.yaml, cli-sse.yaml, and cli-agent.yaml.

The simplest run

- type: cli-run
  name: build
  program: '"go"'
  args: '["build", "./..."]'
  workDir: /src
  env: [PATH, HOME]
  timeout: 5m

The chain carries on with {exitCode, stdout, stderr, lines}. A non-zero exit errors the block by default so the error path sees it; onExit: continue leaves the decision to a later block.

program is a CEL expression, so a constant is written '"go"': quoted once for YAML and once for CEL. A bare name is resolved through $PATH; an absolute path works too. No allow list is needed when the program is a literal.

Watching the output

An events path runs once per output line, and once when the command exits:

  emit: [stdout, stderr, exit]
  events:
    process:
      - type: log
        name: line
        settings:
          message: body.text

Each event's body is {type, text, index}, where type is stdout or stderr. The final one is {type: "exit", exitCode, lines}; it fires for every run, even one that printed nothing, and goes through emit like any other. samples/cli-sse.yaml leaves it out because the route's own finalEvent already carries the exit status.

The path is an observer and its result is discarded, so a broken sink is a log line rather than a failed message.

Streaming to an HTTP caller

Point that path at an sse-event behind an SSE route and the caller sees output live:

source:
  connector: api
  type: http
  settings:
    path: /build
    sse:
      enabled: true
      maxDuration: 5m
      finalEvent: true
      finalEventName: done
process:
  - type: cli-run
    name: build
    program: '"go"'
    args: '["build", "./..."]'
    emit: [stdout, stderr]
    events:
      process:
        - type: sse-event
          name: line
          settings:
            event: line
            data: '{"stream": body.type, "text": body.text}'
            ifClosed: stop

Nothing names the stream: the route stamps its address into vars.sseStream, and an event message carries the parent's variables. cli-run blocks until the command exits, so the request stays open for the whole run.

The events path runs between reads of the process's pipes, so a slow client blocks the child on write instead of letting its output pile up in memory. With ifClosed: stop, a caller that hangs up stops the command.

Set the command's own timeout below the route's maxDuration, so the command gives up first and the caller learns why.

Giving a model a toolbox

Because program is an expression, an ai-agent tool branch can offer a model a set of commands and let it choose. allow is checked on every message, before anything is spawned.

- type: cli-run
  name: run
  program: vars.program
  args: vars.args
  allow: [ls, df, date]
  env: [PATH]
  workDir: /tmp
  onExit: continue

Have the flow map a command name to a program, rather than letting the model supply one:

- type: multi-transform
  name: resolve
  settings:
    transforms:
      - setVar: program
        value: >
          body.command == "list-files" ? "ls" :
          body.command == "disk-usage" ? "df" : ""

A name outside the list maps to "", so nothing is spawned; anything else the mapping could produce still has to be on allow. In its own sourceless flow, the mapping is testable with no model and no API key, as samples/cli-agent_test.yaml does.

What keeps this safe

The allow list is the boundary when you declare one. allow is optional, and a block without one may run anything; the builder warns at startup when such a block takes its program from the message.

Entries may be bare names or absolute paths, compared after resolution: a bare name matches the absolute path $PATH resolves it to, and /bin/../bin/sh cannot dodge an entry for /bin/sh. Symlinks are not followed, because on a busybox system following them would make ["cat"] also admit sh. Every entry is resolved at build time, so one naming an uninstalled program fails at startup.

No shell is involved: arguments are passed as argv, so a semicolon, a pipe or a $(…) in a model-supplied argument is just bytes to the callee. The child sees exactly the variables named in env and no others, so it cannot read an API key the integration holds. Interpreters are refused on an explicit list unless allowInterpreters: true, because listing sh or python allows everything they can reach.

Declare an allow list for anything you deploy. A flow the internet can reach with no allow list is remote code execution, whatever else the flow does.

The interpreter check is a guardrail, not a sandbox. The list can never be complete (git runs a pager, tar has --to-command, find has -exec), so write the allow list as narrowly as the job allows and treat everything on it as something the caller can run.

On this page