> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dagraph.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Run bash and Python exec nodes in dagraph workflows

> Mix bash and python_exec nodes with LLM agent calls in a single DAG — collect system data, transform outputs, and pass results downstream as artifacts.

Not every step in a workflow needs an LLM. Sometimes you need to run a shell command to collect system information, post-process an agent's JSON output with a Python script, or call an external tool that doesn't have an API wrapper. dagraph's `bash` and `python_exec` node types let you mix code execution with LLM calls in the same DAG, with stdout becoming the node's artifact that downstream nodes can reference just like any agent output.

## How exec nodes fit into a DAG

Exec nodes follow the same dependency model as agent nodes. Their output (stdout) is stored as an artifact and referenced by downstream nodes via `{{ node_id }}`. A non-zero exit code fails the node and halts any downstream nodes that depend on it.

The `code_and_llm.yaml` example collects system information with bash, parses it with Python, then summarizes it with an LLM:

```yaml theme={null}
name: code_plus_llm
description: |
  Mix exec nodes with LLM nodes. A bash node collects system info, a
  python_exec node post-processes it, then an agent summarizes it in prose.

budget:
  max_tokens: 10000
  max_usd: 1.00

nodes:
  # Wave 1: collect raw system data
  - id: collect
    type: bash
    command: |
      echo "hostname: $(hostname)"
      echo "python: $(python3 --version 2>&1)"
      echo "uptime: $(uptime | tr -s ' ')"
    timeout_seconds: 10

  # Wave 2: parse the raw text into structured JSON
  - id: parse
    type: python_exec
    depends_on: [collect]
    timeout_seconds: 10
    code: |
      import json
      {% raw %}lines = {{ collect | tojson }}.strip().split("\n"){% endraw %}
      result = {line.split(":", 1)[0].strip(): line.split(":", 1)[1].strip()
                for line in lines if ":" in line}
      print(json.dumps(result, indent=2))

  # Wave 3: LLM summarizes the structured data
  - id: narrate
    type: agent
    model: claude-haiku-4-5-20251001
    max_output_tokens: 300
    depends_on: [parse]
    prompt: |
      Summarize this system-info JSON in 2–3 plain-English sentences
      for a non-technical reader:

      {{ parse }}
```

Run it:

```bash theme={null}
agentgraph run code_and_llm.yaml --sandbox inprocess
```

<Note>
  Any DAG that contains `bash` or `python_exec` nodes requires the `--sandbox` flag. Without it, dagraph refuses to run the DAG to prevent accidental code execution.
</Note>

## The `bash` node

Use `bash` to run shell commands. The `command` field is a Jinja template — you can interpolate DAG inputs and upstream node outputs just like in an agent prompt.

```yaml theme={null}
- id: fetch_prices
  type: bash
  command: |
    curl -s "https://api.example.com/prices?symbol={{ symbol }}" \
      -H "Authorization: Bearer ${API_TOKEN}"
  timeout_seconds: 30
  env:
    API_TOKEN: "${API_TOKEN}"    # passed from the environment
```

Key fields:

| Field              | Default   | Description                                                  |
| ------------------ | --------- | ------------------------------------------------------------ |
| `command`          | required  | Shell command, Jinja-rendered against inputs and dep outputs |
| `timeout_seconds`  | 300       | Fail the node if execution takes longer                      |
| `env`              | `{}`      | Additional environment variables for the process             |
| `image`            | `null`    | Docker image to use (docker sandbox only)                    |
| `max_output_bytes` | 1,000,000 | Truncate stdout beyond this limit                            |

## The `python_exec` node

Use `python_exec` to run Python code inline. The `code` field is also Jinja-rendered, which means curly braces in Python code (f-strings, dict literals, format strings) need to be wrapped in `{% raw %}...{% endraw %}` to prevent Jinja from interpreting them:

```yaml theme={null}
- id: parse
  type: python_exec
  depends_on: [collect]
  code: |
    import json
    {% raw %}
    data = {{ collect | tojson }}
    parsed = json.loads(data)
    summary = {k: v for k, v in parsed.items() if v}
    print(json.dumps(summary, indent=2))
    {% endraw %}
```

Without `{% raw %}`, Jinja sees `{{ collect | tojson }}` as a template expression (which is correct), but it also tries to interpret `{k: v for k, v in parsed.items()}` as a template block and fails.

<Tip>
  The `print()` call is how your Python code returns its result. Whatever you print to stdout becomes the node's artifact. Print a JSON string if downstream nodes or agents need structured data.
</Tip>

## Sandbox backends

dagraph supports two sandbox backends, selected via `--sandbox`:

<Tabs>
  <Tab title="inprocess (subprocess)">
    Runs the command or script as a child process of the dagraph scheduler. Fast and simple — no Docker required.

    ```bash theme={null}
    agentgraph run code_and_llm.yaml --sandbox inprocess
    ```

    Use `inprocess` when you own the code being executed — your own scripts, trusted utilities, or commands you've written yourself.
  </Tab>

  <Tab title="docker (container)">
    Runs each exec node in a fresh, ephemeral Docker container. The container is destroyed after the node completes. Better isolation — the code cannot access the host filesystem or network beyond what the container image allows.

    ```bash theme={null}
    agentgraph run code_and_llm.yaml --sandbox docker
    ```

    Specify a custom image per node if you need a particular runtime:

    ```yaml theme={null}
    - id: run_analysis
      type: python_exec
      image: python:3.12-slim       # uses this image instead of the default
      code: |
        import pandas as pd
        ...
    ```

    Requires Docker to be installed and running on the host.
  </Tab>
</Tabs>

<Warning>
  Use `--sandbox docker` for any code you did not write yourself or that comes from user-controlled input. The `inprocess` sandbox runs code in the same process space as dagraph, so malicious code can access environment variables, API keys, and the filesystem. Docker containers are isolated by default.
</Warning>

## Output size limit

Both `bash` and `python_exec` nodes default to a 1 MB stdout limit. If a node's output exceeds `max_output_bytes`, dagraph truncates it and fails the node with an error. Override the limit per node if you expect large outputs:

```yaml theme={null}
- id: large_export
  type: bash
  command: "cat large_dataset.json"
  max_output_bytes: 10_000_000   # allow up to 10 MB
```

Downstream agent nodes receive this text in their context window — keep outputs as compact as possible to avoid inflating token costs.

## Passing exec output to an LLM

Exec node output is available in downstream agent prompts via `{{ node_id }}` — exactly like any other node:

```yaml theme={null}
- id: summarize
  type: agent
  model: claude-haiku-4-5-20251001
  depends_on: [parse]
  prompt: |
    Here is a JSON summary of the system:
    {{ parse }}

    Explain what it shows in one paragraph.
```

<CardGroup cols={2}>
  <Card title="Parallel agents" icon="arrows-split-up-and-left" href="/guides/parallel-agents">
    Run exec nodes and agent nodes in parallel waves across the same DAG.
  </Card>

  <Card title="Multi-provider fallback" icon="shield" href="/guides/multi-provider-fallback">
    Add fallback chains to the agent nodes downstream of your exec steps.
  </Card>
</CardGroup>
