Skip to content

12. Automating

The previous step wrote a reminder in Python. It works, it is tested, and it has one flaw: changing the delay, the recipient or the wording takes a developer and a redeploy.

Kewos has a second tool for that: the flow engine, under Workflows. A trigger, conditions, actions, wired on a canvas. No code, and the customer can open it and change it.

This step ships a flow with the module, so the automation exists from the moment it is installed instead of being rebuilt on every instance.

They are not for the same thing

This is not “one replaces the other”. The boundary is sharp and worth learning once:

The code from step 11A flow
What starts itthe clock (every morning)an event: a record created, updated, deleted, or a click
Who can change ita developerthe user, in the Workflows screen
What it can doanything Python can dothe available blocks: condition, switch, create, update, transition, email, notification, AI agent
Where it runsin the scheduled taskin the job queue, asynchronously

A flow has no time trigger. The triggers are record.created, record.updated, record.deleted and “manual”. Sweeping every loan each morning therefore stays the job of the code from step 11. Reacting the moment a copy is declared lost, on the other hand, is exactly a flow.

The file

A module ships its flows in flows/, next to views/. There is nothing to call: the kernel reads that directory when the module loads, the way it reads onboarding.json.

modules/library/flows/lost_copy_alert.json:

{
"ref": "library.lost_copy_alert",
"module": "library",
"name": "Tell the desk when a copy is declared lost",
"description": "A lost copy is a decision someone has to act on.",
"nodes": [
{
"node_key": "loan_changed",
"kind": "trigger.record_updated",
"name": "A loan changed",
"config": { "models": ["library_loan"] },
"pos_x": 40,
"pos_y": 120
},
{
"node_key": "is_it_lost",
"kind": "logic.condition",
"name": "Is it lost?",
"config": { "expression": { "==": [{ "field": "state" }, "lost"] } },
"pos_x": 320,
"pos_y": 120
},
{
"node_key": "tell_the_desk",
"kind": "action.send_notification",
"name": "Tell the desk",
"config": {
"recipients": [{ "type": "group", "id": "library_librarian" }],
"title": "A copy was declared lost",
"body": "Loan {{ trigger.record.id }} was marked lost."
},
"pos_x": 620,
"pos_y": 60
}
],
"edges": [
{ "from_node": "loan_changed", "to_node": "is_it_lost" },
{ "from_node": "is_it_lost", "to_node": "tell_the_desk", "branch": "yes" }
]
}

Four things are worth reading slowly.

ref. It is the flow’s identity. The kernel installs it once and never rewrites it: after the first install the flow belongs to the customer. Rename it, change the condition, add a branch, and an upgrade will undo none of it. That is the opposite of a view, which is rewritten on every boot.

It arrives switched off. A shipped flow is a draft. Installing a module must never start sending messages to anyone without a person deciding so. It is one toggle in the Workflows screen.

The group is named by its code, not by a numeric id: library_librarian exists on every instance, whereas id 3 means nothing anywhere else.

"branch": "yes". An edge is followed only when its label matches exactly what the block returned. A condition returns yes or no. Writing true, which is what everybody writes the first time, produces no error at all: the flow simply stops after the condition. The kernel now refuses such a file when it loads, with a message naming the branches the block really returns. That guard exists because the example on this page had true in it too.

What it gives you

After kewos reconcile the flow is there, with nothing done in the interface:

The workflow list: the flow shipped by the module, marked active

And it opens like any hand-drawn flow. The customer can add an email, change the condition, or switch it off:

The flow canvas: trigger, condition, notification, with the "yes" branch

Running it for real

Declare a copy lost from the loan form (the Declare lost button from step 6). Then look:

Fenêtre de terminal
# The run, and each step it went through.
psql -c "select id, flow_id, state from kws_flow_run order by id desc limit 1"
psql -c "select node_key, state from kws_flow_run_step order by id"
id | flow_id | state
----+---------+---------
1 | 1 | success
node_key | state
---------------+---------
is_it_lost | success
tell_the_desk | success

And the notification arrived, with {{ trigger.record.id }} resolved:

{"title": "A copy was declared lost", "body": "Loan 1 was marked lost."}

The trap that says nothing

A flow does not run inside the request that triggered it. It is put on the job queue, and the worker is what runs it. With no worker the message stays in the queue: no error, no row in kws_flow_run, nothing in the interface. The flow looks broken when it is waiting.

Writing this page, the first run produced nothing for exactly that reason. The check is one line:

Fenêtre de terminal
redis-cli LLEN dramatiq:default # > 0 with no worker running
kewos worker # start one, and the queue drains

What the customer can do next

None of the following needs a developer:

  • add an action.send_mail to the member beside the notification;
  • replace the condition with a switch on the state and handle returned differently;
  • wire in an ai.agent that drafts the message;
  • switch the flow off over the summer.

That is the point of the exercise: what you ship is a starting point, not a carved rule.

Next

The module acts on its own and reacts to events. What remains is what it should do with files: Files.