This is the second article in our four-part series on building AI agents with fastWorkflow. Read Part 1 here if you haven't already.
In our previous article, we built a simple messaging workflow using a standalone function. While this approach works well for basic commands, real-world applications are rarely that simple. Most applications maintain state, have complex object models, and require navigating between different contexts.
In this article, we'll explore how fastWorkflow seamlessly integrates with object-oriented programming to create stateful AI agents. We'll transform our simple messaging example into a class-based application that maintains user state.
Before diving into the code, let's understand why moving from functions to class methods is so important for building sophisticated AI agents:
- State Management: Classes encapsulate state, allowing your agent to remember information between commands.
- Context Awareness: Different commands make sense in different contexts - classes provide natural boundaries.
- Finite State Machines: Complex workflows often behave like state machines, where actions change the available options.
- Object Hierarchies: Real applications have complex object models with parent-child relationships.
- Code Organization: Classes provide a natural way to group related functionality.
Let's see how fastWorkflow makes it easy to work with classes and maintain state across interactions.
We'll transform our simple messaging app from the previous article into a class-based version. Here's what we're going to build:
- A
Userclass that maintains the sender's name - A startup command that initializes the user context
- A send_message command that operates on the user instance
Tip
You can fetch messaging_app_2 code using fastworkflow examples fetch messaging_app_2 if you want to skip writing the code
Let's start by comparing the application code between the two versions.
In our first example, we had a simple function:
# messaging_app_1/application/send_message.py
def send_message(to: str, message: str) -> str:
print(f"Sending '{message}' to {to}")Now, we'll transform this into a class with state:
# messaging_app_2/application/user.py
class User:
"""Simple user class representing the current messaging user."""
def __init__(self, name: str):
self.name = name
def send_message(self, to: str, message: str):
"""Send a message to the target user (prints to stdout)."""
print(f"{self.name} sends '{message}' to {to}") Key differences:
- We've created a
Userclass instead of a standalone function - The class maintains state (the user's name)
- The
send_messagemethod includes the sender's name in the output - We need to initialize the class with a name before we can use it
In our first example, we had a single command file at the root level:
messaging_app_1/
├── _commands/
│ └── send_message.py
Now, we need to organize our commands by context:
messaging_app_2/
├── _commands/
│ ├── startup.py
│ └── User/
│ └── send_message.py
Key differences:
- We've added a
startup.pycommand to initialize the user context - We've moved
send_message.pyinto aUser/directory to indicate it operates on a User instance - This directory structure mirrors our object model, making it clear which commands operate on which objects
The startup command is entirely new. It's responsible for initializing our application state:
# messaging_app_2/_commands/startup.py
import fastworkflow
from fastworkflow.train.generate_synthetic import generate_diverse_utterances
from pydantic import BaseModel, Field
from ..application.user import User
class Signature:
"""Initialize the workflow with a root User context."""
class Input(BaseModel):
name: str = Field(
description="Name of a person",
examples=['John', 'Jane Doe'],
default='DefaultUser'
)
plain_utterances = [
"start messaging session",
"initialize user context",
"login as user Billy",
]
@staticmethod
def generate_utterances(workflow: fastworkflow.Workflow, command_name: str) -> list[str]:
"""Generate training utterances for LLM-based intent matching."""
return [
command_name.split('/')[-1].lower().replace('_', ' ')
] + generate_diverse_utterances(Signature.plain_utterances, command_name)
class ResponseGenerator:
"""Create a User instance and attach it as the root command context."""
def __call__(
self,
workflow: fastworkflow.Workflow,
command: str,
command_parameters: Signature.Input,
) -> fastworkflow.CommandOutput:
# Initialize the root context
workflow.root_command_context = User(command_parameters.name)
response = (
f"Root context set to User('{command_parameters.name}')."
f"Now you can call commands exposed in this context."
)
return fastworkflow.CommandOutput(
workflow_id=workflow.id,
command_responses=[
fastworkflow.CommandResponse(response=response)
]
)Key points:
- The startup command takes a
nameparameter to initialize the user - It creates a new
Userinstance with that name - It sets this instance as the
root_command_contextfor the workflow - This establishes the initial state for our application
- The utterances like "login as user Billy" will be recognized as intent to run this command
Now, let's modify our send_message command to work with the User class:
# messaging_app_2/_commands/User/send_message.py
import fastworkflow
from fastworkflow.train.generate_synthetic import generate_diverse_utterances
from pydantic import BaseModel, Field
from ...application.user import User
# the signature class defines our intent
class Signature:
class Input(BaseModel):
to: str = Field(
description="Who are you sending the message to",
examples=['jsmith@abc.com', 'jane.doe@xyz.edu'],
pattern=r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
)
message: str = Field(
description="The message you want to send",
examples=['Hello, how are you?', 'Hi, reaching out to discuss fastWorkflow'],
min_length=3,
max_length=500
)
plain_utterances = [
"Tell john@fastworkflow.ai that the build tool needs improvement",
]
@staticmethod
def generate_utterances(workflow: fastworkflow.Workflow, command_name: str) -> list[str]:
"""This function will be called by the framework to generate utterances for training"""
return [
command_name.split('/')[-1].lower().replace('_', ' ')
] + generate_diverse_utterances(Signature.plain_utterances, command_name)
class ResponseGenerator:
"""Call the User.send_message instance method."""
def _process_command(self, workflow: fastworkflow.Workflow, input: Signature.Input) -> None:
user: User = workflow.command_context_for_response_generation
user.send_message(to=input.to, message=input.message)
def __call__(self, workflow:
fastworkflow.Workflow,
command: str,
command_parameters: Signature.Input) -> fastworkflow.CommandOutput:
"""The framework will call this function to process the command"""
self._process_command(workflow, command_parameters)
response = (
f'Response: The message was printed to the screen'
)
return fastworkflow.CommandOutput(
workflow_id=workflow.id,
command_responses=[
fastworkflow.CommandResponse(response=response)
]
)Key differences from the function-based version:
- The import path has changed to reference the User class
- The most important change is in the
_process_commandmethod:user: User = workflow.command_context_for_response_generation user.send_message(to=input.to, message=input.message)
- Instead of calling a global function, we're accessing the current command context (our User instance)
- We're calling the
send_messagemethod on that instance - This allows the method to access the user's state (name)
To run our new class-based workflow, we follow the same steps as before:
# Train the workflow
fastworkflow train ./messaging_app_2 ./messaging_app_2/fastworkflow.env ./messaging_app_2/fastworkflow.passwords.env
# Run the workflow
fastworkflow run ./messaging_app_2 ./messaging_app_2/fastworkflow.env ./messaging_app_2/fastworkflow.passwords.envWhen you start the workflow, you'll need to initialize the user context first:
User > login as Alice
Context: None
Command: startup
Command parameters: Input(name='Alice')
Root context set to User('Alice').Now you can call commands exposed in this context.
User > tell bob@example.com hello from the stateful workflow
Context: User
Command: User/send_message
Command parameters: Input(to='bob@example.com', message='hello from the stateful workflow')
Response: The message was printed to the screen
Behind the scenes, you'll see the console output:
Alice sends 'hello from the stateful workflow' to bob@example.com
Notice how the message now includes the sender's name, which is maintained as state in our User class.
One of the most powerful features of fastWorkflow is its ability to navigate object hierarchies. The framework automatically:
- Recognizes command contexts: Commands in the
User/directory are automatically associated with User instances - Maintains the current context: The
workflow.command_context_for_response_generationproperty gives you access to the current context - Routes commands to the right objects: When you say "send message", fastWorkflow knows to look for that command in the current context
This context-awareness is crucial for building complex applications where different commands make sense in different contexts.
With this class-based approach, you can now build much more sophisticated applications:
- Multiple Users: Create a system with multiple user instances
- Nested Objects: Build complex object hierarchies (e.g., User → Conversation → Message)
- State Transitions: Change the available commands based on the current state
- Persistent State: Save and restore object state between sessions
All of this is possible because fastWorkflow understands your application's object model and can map natural language commands to the appropriate methods on the right objects.
We've taken a significant step forward by moving from standalone functions to stateful class methods. In the next article, we'll explore how to build even more sophisticated applications using inheritance and context hierarchies.
Next up: Part 3: Building Context Hierarchies with Inheritance in fastWorkflow
- Classes provide a natural way to maintain state in your AI-powered workflows
fastWorkflowseamlessly integrates with object-oriented programming- The directory structure of your commands mirrors your object model
- The startup command initializes your application state
- Context-aware command routing ensures commands are executed on the right objects
- This approach enables building complex, stateful applications with natural language interfaces
