Skip to content

How to build Trigger-Action workflows with local LLMs below 8B (1/n)

Introduction

Automating complex tasks using workflow tools has been there for a while now. The rise of LLMs and modern tools such as N8N, Zapier or Make makes it really easy. These workflows, also called Trigger-Action programs (i.e. IF => THEN => ELSE programs) are now everywhere.

This is the first part of an article series where I'll show you how to generate Trigger-Action programs using LLMs for any kind of workflow engine.

We'll start by approaching the problem in a top-down manner, from the most abstract (natural language) to the most concrete (i.e. function calls).

We'll see what it takes to process a user query in the context of home automation systems:

When the electricity price is below $0.4/kWh and my Tesla is plugged, turn on charging. However I always need to have an autonomy of 100 miles.

Mapping user queries to workflows: Data Modeling

To map a user query to a workflow, we first need an abstract representation of a trigger-action program. This will serve as a foundation to generate structured outputs with LLMs.

As I write this article, I'm also developing llm-tap, a lightweight and extensible library for building Trigger-Action programs with LLMs and I'll show some snippets to explain the relevant pieces.

Data model for workflows

We'll start by mapping programs using Python data classes as a specification (See my 2018 talk on the topic Unexpected Dataclasses):

A Trigger is an event that will make the whole program start.

python
@dataclass
class Trigger:
    description: str

A Condition is a predicate, i.e. something that is true or false. A ConditionSet is a set of conditions grouped by an operator.

python
@dataclass
class Condition:
    description: str

@dataclass
class ConditionSet:
    class Operator(Enum):
        AND = "AND"
        OR = "OR"

    operator: Operator
    conditions: list[Condition]

An Action represents a specific operation or task that should be executed when certain conditions are met. It’s the "do this" part of the automation.

python
@dataclass
class Action:
    description: str

A Branch connects conditions to actions. It specifies:

  • Whether the branch is taken (conditions_value=True or conditions_value=False) based on if the ConditionSet is satisfied.
  • Which actions should be carried out for that outcome.
python
@dataclass
class Branch:
    conditions_value: bool
    actions: list[Action]

A Rule models a decision point within the workflow.

python
@dataclass
class Rule:
    name: str
    condition_sets: list[ConditionSet]
    branches: list[Branch]

A Workflow is the full automation program. It ties everything together:

python
@dataclass
class Workflow:
    name: str
    description: str
    triggers: list[Trigger]
    rules: list[Rule]

Let's write manually and model a simplified version of the user query:

When the electricity price is below $0.4/kWh and my Tesla is plugged, turn on charging.

python
Workflow(
  triggers=[
    Trigger(
      description="Tesla plug state changes"
    ),
    Trigger(
      description="Electricity price changes"
    ),
  ],
  rules=[
    Rule(
      name="Charge Tesla",
      condition_sets=[
        ConditionSet(
            operator=Operator.AND,
            conditions=[
              Condition(
                description="Electricity price is below $0.4/kWh"
              ),
              Condition(description="Tesla is plugged in"),
            ],
        )
      ],
      branches=[
        Branch(
          conditions_value=True,
          actions=[
            Action(description="Turn on Tesla charging")
          ],
        ),
        Branch(
          conditions_value=False,
          actions=[
            Action(description="Turn off Tesla charging")
          ],
        ),
      ],
    )
  ],
)

We have a first version of our reference program.

Note that we didn't specify our environment, devices, schemas or anything like that at this stage.

Generate the Workflow object with a LLM locally

Let's try to naively generate a Workflow object with a simple strategy: We'll try to parse the query and force the LLM to generate a Workflow using function calling (tool calling - structured outputs).

I included a thin wrapper of the grammar-constraint generation of llama.cpp (and the python bindings) in llm-tap.

python
import os
from llm_tap import llm
from llm_tap.models import Workflow

system_prompt = "Parse the user query and answer using JSON"
prompt = """
When the electricity price is below $0.4/kWh
and my Tesla is plugged, turn on charging.
"""

#: Use any GGUF model
model="~/.cache/py-llm-core/models/qwen2.5-1.5b"
# model="~/.cache/py-llm-core/models/llama-3.2-3b"
# model="~/.cache/py-llm-core/models/llama-3.1-8b"
# model="~/.cache/py-llm-core/models/qwen3-4b"
# model="~/.cache/py-llm-core/models/mistral-7b"

#: model should be the path to a GGUF model
with llm.LLamaCPP(model=model) as parser:
    workflow = parser.parse(
      data_class=Workflow,
      prompt=prompt,
      system_prompt=system_prompt,
    )
    print(workflow)

The result is:

python
Workflow(
  triggers=[
    Trigger(
      description="When the electricity price is below $0.4/kWh"
    ),
    Trigger(
      description="When my Tesla is plugged"
    ),
  ],
  rules=[
    Rule(
      name="Turn on charging",
      condition_sets=[
        ConditionSet(
          conditions=[
            Condition(
              description="When the electricity price is below $0.4/kWh"
            ),
            Condition(
              description="When my Tesla is plugged"
            ),
          ],
          operator=Operator.AND,
        )
      ],
      branches=[
        Branch(
          conditions_value=True,
          actions=[
            Action(description="Turn on charging")
          ],
        )
      ],
    )
  ],
)

Some observations:

  • The trigger may be interpreted as a condition or as an event. Specifying the environment as structures will help clarify the output.
  • The answer lacks the conditions_value=False branch

None of the open weights models tested managed to add the branch conditions_value=False. Testing with OpenAI's latest small model gpt-4.1-nano was however a limited success. The 2nd branch was created but actions were left empty.

Clarifying triggers vs conditions

One of the most common sources of confusion when designing automation workflows—especially when parsing natural language—is how to distinguish triggers (events) from conditions (property assertions).

This distinction is critical for creating robust, predictable automation logic.

Triggers as events

A trigger is an external event that causes the workflow to start running or to re-evaluate. Triggers are typically tied to something happening in the environment.

Examples:

  • "Tesla plug state changes" (i.e., the Tesla is plugged in or unplugged)
  • "Electricity price changes"
  • "A new email is received"
  • "Time reaches 8:00 AM (a scheduled event)"

Key Features:

  • Mark the moment when the workflow becomes active.
  • Don’t have a truth value—they’re not true or false, just events that occur or not.

Triggers written as conditions

Users often express triggers in natural language as if they were conditions:

"When the electricity price is below $0.4/kWh"

Here, the real trigger might be an underlying event ("Electricity price changes"), while the price being below threshold is a property to check (a condition).

Similarly, "when my Tesla is plugged" could refer to the event "Tesla plug state changes," and the actual state (plugged/unplugged) is a property.

Natural way of writing events

In everyday speech, people tend to conflate triggers and conditions:

  • "When it rains, close the windows." (implies the event of rain starting)
  • "If it is raining, close the windows." (a state/condition check)

A LLM-powered workflow builder should be able to handle both forms but workflows must internally split them into events (what to listen to) and conditions (what must be true to proceed).

Discriminating events vs property-based conditions

As we need a way to discriminate events from property-based conditions, we'll resort to ask questions:

  • Is this describing a moment when something changes? => Trigger/Event
  • Is this asserting the current state/properties of the world? => Condition

Improving the prompts and describing the environment

Improvements on prompts and descriptions

To design runnable workflows, we need to make our model much more concrete and add specificity. We'll do that step by step to keep the ability to apply the method to any workflow engine.

Let's start by adding more info in both system prompts and user prompt:

python
system_prompt = """You are an automation system assistant.

Your role is to help design data structures representing the
user's query as a Workflow.

When analyzing the user's query, identify the events (triggers)
and isolate them from conditions.

To help identify if the user's query is specifying an event
(trigger) or a condition, answer the questions:

- Is this describing a moment when something changes? => Trigger/Event
- Is this asserting a *current* property? => Condition

When designing rules, think about execution branches covering
both cases when conditions are true and when they are false.

To describe a user query, translate it into pseudo-code:

WHEN < event > happen
IF < condition_01 > AND < condition_0Z >
THEN < branch_01 >
< action_1.1 >
< action_1.2 >
ELSE
< action_2.1 >
< action_2.2 >

Answer using JSON.
"""

prompt = """
# Home Automation Environment

- Tesla charging system (on/off)
- Tesla monitoring sensors
  - Battery level in %
  - Autonomy level in miles
  - Plug status
- Electricity price broadcast
  - Price change events
  - Current price
- Time management broadcast
  - CRON-like events

# Query

> When the electricity price is below $0.4/kWh
and my Tesla is plugged, turn on charging.

# Instructions

Describe the workflow

"""

Here is the result:

python
Workflow(
  name="Turn on charging when electricity price is below $0.4/kWh and Tesla is plugged",
  description="A workflow that turns on the charging system when the electricity price is below $0.4/kWh and the Tesla is plugged.",
  triggers=[
    Trigger(description="When the electricity price is below $0.4/kWh"),
    Trigger(description="When the Tesla is plugged"),
  ],
  rules=[
    Rule(
      name="Turn on charging",
      condition_sets=[
      ConditionSet(
        conditions=[
          Condition(
            description="When the electricity price is below $0.4/kWh"
            ),
          Condition(description="When the Tesla is plugged"),
          ],
          operator=Operator.AND,
          )
      ],
      branches=[
        Branch(
          conditions_value=True,
          actions=[Action(description="Turn on charging")],
          ),
        Branch(
          conditions_value=False,
          actions=[Action(description="Do nothing")],
          ),
        ],
      )
    ],
  )

Observations:

  • We gained the branch "Do nothing"
  • The triggers and the conditions are still unclear to the model but we lack a structure to properly specify events vs property-based conditions.

The next steps will involve created more refined structures to clarify this issue.

Structures all the way down

To improve the results and make a step forward a more concrete workflow (but not yet runnable), we'll modify llm-tap models to add specificity.

We'll also change the way to model our workflow : We'll use Colored Petri nets.


Get notified when the part 2 is published:

Advanced Stack