Unified Workflows (shipped design)
Status: DONE / shipped (2026-06-16). The two programmatic-tool-calling scripts
(run_tools_script / run_agents_script) and the entire Automations subsystem were
merged into ONE first-class Workflow concept, then collapsed to a SINGLE form. The
separate Automations subsystem no longer exists: a Workflow with triggers IS what used to
be an “Automation.” Modeled on Claude Code’s own Workflow tool (meta
{name, description, when_to_use} + a script body + save-by-name + discovery).
Skills stay a SEPARATE system — they are not absorbed into workflows. A Skill is an
authored prose PLAYBOOK the LLM reads (progressive disclosure via use_skill); a Workflow
is an EXECUTABLE script. The two keep their own tables, tools and UI.
The unified model
A Workflow is a named, owner-scoped, sandboxed Monty script (Monty = Pydantic’s
deny-by-default Python interpreter: no imports / file / network) - programmatic tool-calling
with loops, fan-out and sub-agents (delegate / delegate_many), running the full
guard-wrapped toolset. There is NO kind. The old script-vs-agents split, the “static
steps” IR (tool/llm/extract) and the “agent prompt” action mode are GONE - everything is a
script (the workflow_script_only_01 migration converted any kind=agent|steps rows to
equivalent scripts and dropped the kind + body columns).
Workflow {
id, owner_sub, org_id,
name (slug), description, when_to_use, # self-describing discovery (Skill-style)
script, # the sandboxed Monty Python body
enabled, source, usage_meta,
integrations: [config_entry_id], # optional: integration tools the script may use
devices: [device_id | "phone:<id>"], # optional: connected Computer Service tools
triggers: [ {kind: schedule|interval|webhook|manual|event|poll, config} ],
condition?: HA-style (entity_state | entity_attribute | time | trigger)
}
- Script body - a sandboxed Monty script. Spawning a sub-agent
(
delegate/delegate_manywith anagent=selector) is just another function in the sandbox, alongside the data tools and the full chat toolset. Inner tool calls run through the chat’s guard-wrapped toolset, so every call honors the per-run security mode (approve_each/judge) + governance + the untrusted-content gate. - Triggers — a Workflow may carry triggers: schedule / interval / webhook / manual / event / poll, plus an optional HA-style condition (entity_state / entity_attribute / time / trigger). A triggered Workflow is the old “Automation.”
- Integrations + devices — a Workflow may scope which integration config-entries and
connected Computer Services (plus the companion phone as
phone:<id>) it may use, on top of the ambient web + first-party tools.
How a Workflow runs
- Inline — the agent runs a Workflow in-chat via the
run_workflowtool (byscript=or by savedname=). The agent authors/manages workflows viasave_workflow/list_workflows/delete_workflow/run_workflow. Enabled workflows’when_to_useis injected into a discovery preamble (mirrors the skills pattern; skills are not reused). - Durable (triggered) — a trigger fires a Workflow in the background via a Temporal
Schedule →
WorkflowScheduleWorkflow→prepare_workflow_run→ a childChatAgentWorkflow→ therun_script_workflowactivity. The activity runs the script headless with the full guard-wrapped toolset + sub-agents +send_message_to_user(the run’s only channel to the user; its own output chat is hidden, so a detached background run can still reach the user).
A headless/detached background run is headless → the guard fail-denies approval
instead of hanging on a HITL card.
!!! note “Later extension: marketplace (publish / adopt)”
A Workflow can be published instance-wide (marketplace_published_at; admin
kill-switch unlisted_by_admin) and adopted by other users as a live reference -
the publisher’s script is the shared definition, but an adopted run executes in the
ADOPTER’s context (their integrations / devices / model / governance, never the
publisher’s entry ids or credentials). Adopted rows are read-only and manual-run only
(POST /workflows/adopted/{id}/run); an adopter may set their OWN triggers/schedule on
an adoption (/workflows/adopted/{id}/triggers, schedule id wfadopt:<adoption_id>:<n>),
which fire the published workflow with WorkflowFireSpec.adopter_sub set. Browse/publish
live on the shared /marketplace surface (agents + skills + workflows).
Why script-only
Collapsing to a single script form removes the dead branches (the WorkflowStep IR, the
agent-prompt mode) and the script-vs-agents tool split. What’s available to a script is
decided by the run’s capabilities + permissions, not by which of several tools the model
picked. A script is strictly more expressive than the old static step-lists, so nothing is
lost; everything authored as a workflow is one authoring + one execution path.
Code map
- Trigger engine -
src/personal_agent/workflows/(executor, triggers, conditions, schedule_sync, event_dispatcher, tool_emitter, events). The Automations subpackage is gone; this is its successor. - Contracts -
src/personal_agent/contracts/workflow_trigger.py(WorkflowFireSpec,WORKFLOW_SCHEDULE_WORKFLOW);RunSpec.script+RunSpec.workflow_id. - DB — one
workflowstable (+ aworkflow_secretswebhook sidecar). The migration preserved existing automation ids + schedules. - REST — one
/workflowssurface: CRUD + enable/disable/confirm/run/runs/rotate-token, plus the public/webhooks/workflows/{id}. - Frontend — a “Workflows” drawer page (the list), a dedicated
/workflows/newand/workflows/:id/editform page (not a dialog). The Hooks tab lives on the Workflows list page.
Invariants preserved
- Contract #1/#2 — usage recorded per
ModelResponse, idempotent on(run_id, request_index); sub-agents keep their ownrun_id+parent_run_id+ usage. - Contract #6 — toolsets snapshotted into the
RunSpecat run start; the workflow never queries live DB state during a run/replay. - Contract #13 — when an untrusted source is in a run, the assembler drops high-privilege first-party / device tools; the durable worker mirrors this per-request.
- Contract #14 —
enforce_classificationruns at every model-resolution entry, including the triggered durable path. - Schedule survival — Temporal Schedules are id-stable (
autorun:{id}); a boot reconcile pass re-syncs schedules against theworkflowstable.