Documentation

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_many with an agent= 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_workflow tool (by script= or by saved name=). The agent authors/manages workflows via save_workflow / list_workflows / delete_workflow / run_workflow. Enabled workflows’ when_to_use is 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 ScheduleWorkflowScheduleWorkflowprepare_workflow_run → a child ChatAgentWorkflow → the run_script_workflow activity. 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 workflows table (+ a workflow_secrets webhook sidecar). The migration preserved existing automation ids + schedules.
  • REST — one /workflows surface: CRUD + enable/disable/confirm/run/runs/rotate-token, plus the public /webhooks/workflows/{id}.
  • Frontend — a “Workflows” drawer page (the list), a dedicated /workflows/new and /workflows/:id/edit form 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 own run_id + parent_run_id + usage.
  • Contract #6 — toolsets snapshotted into the RunSpec at 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_classification runs 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 the workflows table.