Here’s the first “chapter” of the guide, focusing on the foundational anatomy of a minimal Pipulate workflow, using the “widget shim” (700_widget_shim.py
) as the primary example. This lays the groundwork for understanding how more complex widgets are built upon this core structure.
Pipulate Workflow Development Guide
Chapter 1: The Anatomy of a Minimal Pipulate Workflow – The “Shim”
1.1 Introduction to Pipulate Workflows
Pipulate workflows are designed as linear, step-by-step processes, often intended for end-users who interact with a web interface without needing to see or modify the underlying Python code. The core philosophy emphasizes:
- Local-First Operation: Workflows run entirely on the user’s machine, ensuring data privacy and control.
- Server-Side State: All workflow state is managed on the server, simplifying the client-side and making state changes observable.
- FastHTML & HTMX: The user interface is built using FastHTML for Python-centric HTML generation and HTMX for dynamic, server-rendered updates with minimal client-side JavaScript.
- Explicit Processes: Workflows are “WET” (Write Everything Twice/Explicit), making them easy to understand, debug, and port from experimental scripts or Jupyter Notebooks.
- Chain Reaction Pattern: Each step explicitly triggers the next, creating a reliable and predictable flow of execution.
The plugins/700_widget_shim.py
file serves as the most basic template—a “blank canvas”—for creating new workflows. Understanding its structure is key to developing any Pipulate widget or multi-step process.
1.2 Core Components of a Workflow Class
Let’s dissect the WidgetDesigner
class within 700_widget_shim.py
(or a similarly structured minimal workflow).
1.2.1 File Naming and Discovery
- Location: Workflow plugin files reside in the
plugins/
directory. - Naming Convention: Files typically start with a numeric prefix (e.g.,
700_widget_shim.py
). This prefix dictates the order in which workflows appear in the UI menu. - Automatic Registration:
server.py
automatically discovers and registers Python files in theplugins/
directory. The numeric prefix is stripped to form the internalAPP_NAME
for the workflow (e.g.,700_widget_shim.py
becomeswidget_shim
). Files starting withxx_
or containing parentheses are typically skipped, allowing for in-progress or experimental work.
1.2.2 Class Definition and Configuration Constants
Every workflow is encapsulated within a Python class. This class contains several key configuration constants:
APP_NAME
(e.g.,design_widget
in the shim, though for a real plugin it would match the filename likewidget_shim
):- This is the internal identifier for the workflow.
- It’s crucial for constructing URL routes (
/{APP_NAME}/...
) and for namespacing data in thepipeline
database table. - Convention: This should match the filename after stripping the numeric prefix and
.py
extension. - Important: The
APP_NAME
must be different from the public endpoint derived from the filename to avoid conflicts.
DISPLAY_NAME
(e.g.,"Widget Shim"
):- The user-friendly name displayed in the Pipulate UI (e.g., in the “App” dropdown menu).
ENDPOINT_MESSAGE
(e.g.,"Welcome to the Widget Shim! ..."
):- Informational text displayed on the workflow’s initial landing page, guiding the user.
TRAINING_PROMPT
(e.g.,"This is a specialized workflow for designing..."
):- Context provided to the integrated LLM (Ollama) when this workflow is active.
- It can be a direct string or a filename (e.g.,
"my_widget_training.md"
) pointing to a Markdown file in thetraining/
directory. This allows for more extensive context for the LLM.
1.2.3 The __init__
Constructor
The constructor __init__(self, app, pipulate, pipeline, db, app_name=APP_NAME)
is standard:
app
: The FastHTML application instance.pipulate
: An instance of the corePipulate
helper class fromserver.py
. This provides essential methods for state management, UI generation, and LLM interaction.pipeline
: A reference to thepipeline
database table object (managed by MiniDataAPI viafast_app
inserver.py
). This table stores the state of all workflow instances.db
: An instance ofDictLikeDB
fromserver.py
, a key-value store (backed by thestore
SQLite table) for global application settings (like last selected profile, current workflow ID).app_name=APP_NAME
: Sets the internalself.app_name
attribute, usually defaulting to the class’sAPP_NAME
constant.self.message_queue = pip.message_queue
: A queue for sending messages to the user/LLM chat interface in an ordered fashion.
Key Structures Initialized in __init__
:
-
steps
List andStep
NamedTuple: This list defines the sequence of operations in the workflow. Each element is aStep
namedtuple:Step = namedtuple('Step', ['id', 'done', 'show', 'refill', 'transform'], defaults=(None,))
id
(str): A unique identifier for the step, e.g.,'step_01'
. Used in URL routes (/{app_name}/{step_id}
), DOM element IDs, and for internal state tracking.done
(str): The key used to store this step’s primary output or result within the workflow’s JSON state blob in thepipeline
table. This is a critical convention. For example, ifdone='user_input'
, then after this step completes, the state might look like:{"step_01": {"user_input": "some value"}}
.show
(str): A user-friendly name for the step. Often used in UI messages or for the label on a “revert” button.refill
(bool): IfTrue
, the input form for this step will attempt to pre-fill with existing data when the step is revisited (e.g., after a revert or if the workflow is reloaded). IfFalse
, the form will typically show default/suggested values.transform
(function, optional): A lambda or function that can process data from a previous step to suggest an input for the current step. Not used in the simplest shim.
In
700_widget_shim.py
, a single placeholder step is defined:steps = [ Step( id='step_01', done='placeholder', # This step stores a value under the key 'placeholder' show='Step 1 Placeholder', refill=False, ), ]
-
Route Registration: The
__init__
method dynamically registers URL routes for the workflow:- Standard Workflow Routes:
/{app_name}
(GET): Callsself.landing()
to show the initial page./{app_name}/init
(POST): Callsself.init()
to process thepipeline_id
and start the workflow./{app_name}/revert
(POST): Callsself.handle_revert()
to go back to a previous step./{app_name}/finalize
(GET, POST): Callsself.finalize()
to display the finalize button or process finalization./{app_name}/unfinalize
(POST): Callsself.unfinalize()
to unlock a finalized workflow.
- Step-Specific Routes: For each
Step
inself.steps
:/{app_name}/{step.id}
(GET): Calls the method named after the step’s ID (e.g.,self.step_01()
). This method displays the current state or input form for that step./{app_name}/{step.id}_submit
(POST): Calls the method namedf"{step.id}_submit"
(e.g.,self.step_01_submit()
). This method processes the form submission for that step.
- Standard Workflow Routes:
-
self.step_messages
Dictionary: A dictionary holding user-facing messages for various workflow states, keyed bystep.id
. Each step typically has an “input” message (when the user needs to provide data) and a “complete” message. There’s also a standard “finalize” key.self.step_messages = { "finalize": { "ready": "All steps complete. Ready to finalize workflow.", "complete": f"Workflow finalized. Use {pip.UNLOCK_BUTTON_LABEL} to make changes." }, "step_01": { # Matches step.id "input": f"{pip.fmt('step_01')}: Please complete {steps[0].show}.", # Using step.show "complete": f"{steps[0].show} complete. Continue to next step." } }
-
Internal
finalize
Step andself.steps_indices
: A specialStep
tuple for “finalize” is typically appended to theself.steps
list after the plugin-defined steps. This allows the finalization process to be handled consistently with other steps in terms of UI and state.steps.append(Step(id='finalize', done='finalized', show='Finalize', refill=False)) self.steps_indices = {step.id: i for i, step in enumerate(steps)}
self.steps_indices
creates a lookup for finding a step’s position in the list by its ID.
1.3 Core Workflow Engine Methods (The “Shim” Foundation)
These asynchronous methods define the behavior of the workflow.
-
async def landing(self)
:- Renders the workflow’s initial entry page.
- Displays
self.DISPLAY_NAME
andself.ENDPOINT_MESSAGE
. - Presents a
Form
where the user can:- Enter a new
pipeline_id
(a unique key for this run of the workflow). - Select an existing
pipeline_id
from adatalist
populated with previous runs of this specific workflow (app_name
is used for filtering).
- Enter a new
pip.generate_pipeline_key(self)
suggests a default new key (e.g.,DefaultProfile-WidgetShim-01
).- The form POSTs to
/{self.app_name}/init
. - Crucially, it includes a target
Div
(e.g.,Div(id=f"{self.app_name}-container")
) where the HTMX responses from subsequent steps will be rendered.
-
async def init(self, request)
:- Triggered by submitting the
pipeline_id
form on thelanding
page. - Retrieves or generates the final
pipeline_id
. It validates the input: if a user provides an ID that doesn’t match the expected prefix for the current profile and plugin, it assumes the user is creating a new ID and correctly formats it with the current profile/plugin prefix. - Stores this
pipeline_id
in the globaldb
store:db["pipeline_id"] = pipeline_id
. - Initializes the workflow’s state in the
pipeline
table if it doesn’t exist for thispipeline_id
, associating it withself.app_name
:state, error = pip.initialize_if_missing(pipeline_id, {"app_name": self.app_name})
. - Sends confirmation messages to the user via
self.message_queue.add(...)
. - Initiates the Chain Reaction: This is a cornerstone of Pipulate workflows.
init
returns an HTMX response that loads the first step of the workflow:# Simplified from the shim, which uses pip.rebuild for a single step # For a multi-step workflow, pip.rebuild would be more common here. # The shim directly loads step_01. Div( Div(id="step_01", hx_get=f"/{self.app_name}/step_01", hx_trigger="load"), id=f"{self.app_name}-container" # This outer Div replaces the target from landing. )
When this HTML is swapped into the target
Div
fromlanding
, the innerDiv
forstep_01
immediately triggers an HTMX GET request to its own URL (/{app_name}/step_01
), thus loading the first step’s UI.
- Triggered by submitting the
-
Step Handler - GET (e.g.,
async def step_01(self, request)
)- This method is called when a step needs to be displayed (either initially or after a revert).
- It retrieves the current
pipeline_id
fromdb
, reads the workflowstate
usingpip.read_state()
, and gets specificstep_data
usingpip.get_step_data(pipeline_id, step.id, {})
. - It determines the
next_step_id
(e.g.,step_02
orfinalize
). - View Logic:
- Finalized View: If
pip.get_step_data(pipeline_id, "finalize", {}).get("finalized")
is true and this step has data (user_val = step_data.get(step.done)
), it displays a “locked” view of the step’s content. This view must still include the trigger for thenext_step_id
:Div(id=next_step_id, hx_get=f"/{self.app_name}/{next_step_id}", hx_trigger="load")
. - Completed View (Not Reverting): If
user_val
exists (meaning the step was previously completed) andstate.get("_revert_target") != step.id
(meaning we are not explicitly trying to revert to this specific step’s input form), it displays the completed view. This often involvesself.pipulate.revert_control()
to show the stored data and offer a “Revert” button. This view also must include theDiv
to trigger thenext_step_id
. - Input Form View (Default/Reverting): If the step is not complete, or if
state.get("_revert_target") == step.id
, it displays the input form.- It sends an appropriate message from
self.step_messages
to theself.message_queue
. - The
Form
will POST to/{self.app_name}/{step.id}_submit
. - Input fields in the form should be named according to the
step.done
attribute for this step, so their values are correctly picked up by the submit handler. - It includes an empty placeholder
Div(id=next_step_id)
for the next step. This placeholder is crucial: it does not havehx_trigger="load"
at this stage. The trigger is added by the corresponding_submit
handler to ensure the chain reaction proceeds only after successful submission.
- It sends an appropriate message from
- Finalized View: If
-
Step Submit Handler - POST (e.g.,
async def step_01_submit(self, request)
)- This method processes the data submitted from the step’s input form.
- It retrieves form data:
form = await request.form()
, thenuser_val = form.get(step.done, "")
. - It should validate the input (e.g., using
pip.validate_step_input()
). - It updates the workflow’s state:
await pip.update_step_state(pipeline_id, step_id, user_val, self.steps)
. This savesuser_val
into the JSON blob in thepipeline
table, associated withpipeline_id
, under the keystep_id
, and within that, under the key defined bystep.done
. - It updates the LLM’s context with the submitted data:
pip.append_to_history(f"[WIDGET CONTENT] {step.show}:\n{user_val}")
. - It sends a confirmation message to the user via
self.message_queue.add(...)
. - Continues the Chain Reaction: This is a critical part. The method returns an HTML response that typically includes:
- The “completed” view of the current step (often using
pip.revert_control()
). - The explicit trigger for the next step:
Div(id=next_step_id, hx_get=f"/{self.app_name}/{next_step_id}", hx_trigger="load")
. - The entire response is wrapped in a
Div
withid=step_id
, which matches thehx-target
of the form, causing this new content to replace the input form.
- The “completed” view of the current step (often using
-
async def finalize(self, request)
(GET/POST)- GET Request:
- If the workflow is already finalized (checked via
pip.get_step_data(pipeline_id, "finalize", {}).get("finalized")
): Shows a “Workflow is locked” message and aForm
with an “Unlock 🔓” button that POSTs to/{self.app_name}/unfinalize
. - If not finalized but all data-input steps are complete: Shows a
Form
with a “Finalize 🔒” button that POSTs to/{self.app_name}/finalize
. - If not finalized and steps are incomplete: Returns an empty
Div(id="finalize")
(or similar placeholder), effectively showing nothing until prior steps are done.
- If the workflow is already finalized (checked via
- POST Request:
- Calls
await pip.finalize_workflow(pipeline_id)
which sets the{"finalized": True}
flag in the “finalize” step’s data in thepipeline
table. - Sends a confirmation message.
- Calls
pip.rebuild(self.app_name, self.steps)
to re-render the entire workflow UI, now reflecting the locked state.
- Calls
- GET Request:
-
async def unfinalize(self, request)
(POST)- Calls
await pip.unfinalize_workflow(pipeline_id)
which removes the{"finalized": True}
flag. - Sends a confirmation message.
- Calls
pip.rebuild(self.app_name, self.steps)
to re-render the UI in an unlocked state.
- Calls
-
async def handle_revert(self, request)
(POST)- Called when a user clicks a “Revert” button generated by
pip.revert_control()
. - The form submission includes the
step_id
to revert to. - Calls
await pip.clear_steps_from(pipeline_id, step_id, self.steps)
: This crucial method removes data for all steps after the targetstep_id
from the workflow’s state (respecting therefill
attribute of those subsequent steps viapip.PRESERVE_REFILL
logic inserver.py
). - Sets a temporary flag in the state:
state["_revert_target"] = step_id
. The GET handler forstep_id
uses this flag to know it should display its input form rather than its completed view. - Sends a status message.
- Calls
pip.rebuild(self.app_name, self.steps)
to re-render the entire workflow from the specified revert point.
- Called when a user clicks a “Revert” button generated by
-
async def get_suggestion(self, step_id, state)
- This method is called by step GET handlers to pre-fill input forms.
- In the basic shim, it typically returns an empty string:
return ""
. - For more complex workflows, it might implement logic based on
step.transform
or provide hardcoded examples.
1.4 The Chain Reaction Pattern - Explicit Triggering
The progression through a Pipulate workflow relies on a carefully orchestrated “chain reaction” of HTMX partial page updates:
landing
page has a form that POSTs toinit
.init
returns HTML that includes aDiv
withhx-get
forstep_01
andhx_trigger="load"
. This immediately loads the first step.- The
step_01
GET handler displays an input form. - The form POSTs to
step_01_submit
. step_01_submit
returns HTML that shows the completed view ofstep_01
AND includes a newDiv
withhx-get
forstep_02
(orfinalize
) and, crucially,hx_trigger="load"
.- This pattern repeats for each step.
The explicit inclusion of the next step’s loading Div
with hx_trigger="load"
in the response of the current step’s submit handler is vital. It ensures that the next part of the workflow loads only after the current step has been successfully processed and its state saved. This is preferred over relying on event bubbling from a common parent container, as it’s more direct and reliable.
1.5 State Management Recap
- Workflow Instance State: Stored in the
pipeline
table, keyed bypipeline_id
. Thedata
column holds a JSON blob. Each step’s output (usingstep.done
as the key) is an entry within this JSON.// Example for pipeline_id = "MyProfile-MyWorkflow-01" // In 'pipeline' table, 'data' column: { "created": "2025-05-14T...", "updated": "2025-05-14T...", "app_name": "my_workflow", // Added by initialize_if_missing "step_01": { "user_input_for_step1": "some data" // 'user_input_for_step1' is step_01.done }, "step_02": { "another_field": 123 // 'another_field' is step_02.done }, "finalize": { "finalized": true // Set by finalize_workflow } }
- Global Application State: Stored in the
store
table via theDictLikeDB
instance (self.db
). This includes:pipeline_id
: The currently active workflow instance.profile
: The currently selected profile.- Other global settings.
- LLM Context: Managed through the
message_queue
andappend_to_history
methods, ensuring the LLM has the necessary context for each step of the workflow.
1.6 Best Practices for Workflow Development
- State Management:
- Always use
pip.update_step_state
to modify workflow state. - Use
pip.get_step_data
to read state, providing a default empty dict. - Keep state updates atomic and predictable.
- Always use
- UI/UX:
- Use
pip.revert_control
for consistent “completed step” views. - Include clear messages via
self.message_queue
. - Follow the chain reaction pattern for reliable step progression.
- Use
- Error Handling:
- Validate inputs using
pip.validate_step_input
. - Use
pip.logger
for debugging and error tracking. - Handle edge cases gracefully (e.g., missing state, invalid inputs).
- Validate inputs using
- Code Organization:
- Keep step logic focused and single-purpose.
- Use descriptive names for
step.done
keys. - Document complex transformations or state dependencies.
- Testing:
- Test each step’s GET and POST handlers independently.
- Verify state updates and UI updates match expectations.
- Test the revert and finalize flows thoroughly.
This concludes the first “chapter.” I’ve tried to be detailed and reference the existing codebase conventions. Please let me know if this is the right level of detail and if you’d like to proceed to the next part, which would likely cover adding actual input fields to the shim and displaying their values, forming the basis of a very simple custom widget. We can then iterate towards more complex examples like the Markdown widget, incorporating its specific helper methods and client-side JS triggering.