Workflows and schedules
How Hubzoid runs recurring work as markdown tasks or code workflows on one durable engine, with triggers, concurrency, retries, catch-up rules and the account each run acts as.
A Hub can do work on its own: on a clock, when a webhook arrives, or when an operator starts it. Hubzoid gives you two ways to write that work. Both run on the same durable engine inside the hub process, share one run history and one set of controls, and use the same Hub knowledge, tools and access rules as chat.
Two ways to define recurring work
A markdown task is one file in schedule/. Its frontmatter says when it runs and which paths it may change. Its body is plain instructions that the hub's own agent carries out unattended, or it names a command to run instead.
A code workflow is a Python function in workflows/<name>/ decorated with @workflow. You write the exact steps, loops and thresholds, and call the model only where judgment helps.
| Markdown task | Code workflow | |
|---|---|---|
| File | schedule/<task>.md | workflows/<name>/*.py |
| You write | Instructions for the agent, or a run: command | Python with @workflow, @step and hub |
| Triggers | A 5-field cron in server local time, or on_webhook: | A schedule phrase or cron in a named timezone, or manual only |
| Starts on its own | Whenever an enabled task file exists | When the deployment sets HUBZOID_SCHEDULES=1, or under hubzoid gateway |
| Runs at once | One markdown run per hub | One run per workflow, different workflows side by side |
| Waits for chat | Yes. A due task starts only while no chat request is in flight | No |
| After downtime | One catch-up run | No backfill. The next slot runs |
| Acts as | An ordinary account: run_as: in the frontmatter, else the hub default | An ordinary account: run_as= on the decorator, else the hub default |
| Retries | Work is never repeated. Push is retried once | Per step with max_attempts. hub.call_llm is retried once |
| Guide | Markdown tasks | Code workflows |
Start with a markdown task when you can describe the job in a paragraph. Move to a code workflow when the steps must be exact, when a threshold or loop decides what happens, or when you call your own APIs. The packaged Watchtower sample (hubzoid init my-watchtower --template watchtower) is a complete code workflow to copy from.
One engine
Every scheduled run executes on the hub's embedded DBOS (opens in a new tab) engine. There is no separate job server. The engine keeps its run history in a per-hub SQLite file, .hubzoid/dbos.db, or in PostgreSQL when HUBZOID_DBOS_DB or a PostgreSQL DATABASE_URL is set. Workflow state (hub.state and hub.shared_state), pauses and the access audit live in the deployment's operational database, so every bridge on a shared database sees the same pauses.
Because both kinds share the engine, they share:
- one run history, shown by
hubzoid schedule statusand each agent's Runs & schedules tab in the Console - the same
hubzoid schedule pause,resumeandcancelcommands - the same hold while
hubzoid backupcopies the deployment
The engine starts with the hub process whenever there is scheduled work. Markdown tasks and scheduled evals start whenever their files exist. Code workflows fire on schedule only where the deployment is marked with HUBZOID_SCHEDULES=1, or automatically under hubzoid gateway, so a copy of the hub on a laptop does not fire alongside the server.
Runs and steps
A run is one execution of a task or workflow. It has an id, a status, a result and a list of steps. A step is a unit of work whose result the engine saves before the run moves on. When a process restarts, a run resumes after its last completed step.
- Trigger fires
- Run queued
- Steps checkpointed
- Result recorded
- Shown in Console
Every run first records the account it acts as, in a step named hz_md_identity for a markdown task or hz_run_identity for a code workflow. A resumed run reuses that record, so it never switches account midway. A markdown run then has up to four steps:
- work: the agent rounds, or the
run:command - commit: only when the work finished
doneand the task declarescommit:paths. It commits those paths alone and makes no commit when nothing changed - push: only when the task sets
push: trueand the commit step made a commit. It runsgit pull --rebase, thengit push - finish: records the result and archives the webhook events the run handled
A code workflow's steps are your @step functions and each call to hub.call_llm, hub.call_agent, hub.call_jev, hub.publish_artifact or hub.send_email. Code outside a step runs again when a run resumes, so keep side effects inside steps.
Run ids tell you where a run came from:
| Run | Id |
|---|---|
| Scheduled markdown task | md:<task>:<YYYYMMDDTHHMM>, the cron slot it stands for |
| Webhook markdown task | md:<task>:events-<YYYYMMDDTHHMM>-<hash>, from the event files it claimed |
| Manual markdown run | md:<task>:manual-<YYYYMMDDTHHMMSS> |
| Scheduled code workflow | Derived from the hub, the workflow and the slot time |
| Manual code workflow run | Generated by the engine |
Because a scheduled id is derived from its slot, the same slot is never queued twice, even when two scheduler ticks or two processes see it due at once.
Triggers
| Trigger | Applies to | How it works |
|---|---|---|
| Cron | Markdown tasks | schedule: takes a numeric 5-field cron in the local time of the machine running the hub. The scheduler checks every 30 seconds. |
| Schedule phrase | Code workflows | @workflow("daily at 6am", timezone="Europe/London") takes a phrase or a raw cron, evaluated in the workflow's IANA timezone (UTC by default). The dispatcher checks once a minute. |
| Webhook | Markdown tasks | on_webhook: <name> runs the task while verified events wait in its inbox. See Webhooks. |
| Manual | Both | hubzoid schedule run <hub> <name> queues one run now. A code workflow declared with @workflow() and no schedule runs only this way. |
A markdown task has exactly one trigger: schedule: or on_webhook:, never both.
Concurrency
- Markdown tasks run one at a time per hub, across processes. Scheduled evals share this queue. This keeps the git commits and pushes of different tasks from overlapping.
- A due markdown task waits while the hub is answering chat. The scheduler dispatches it on a later tick once no chat request is in flight. The gate applies at start only. A running task continues alongside new chat requests.
- A webhook task has at most one run queued or running. Events that arrive meanwhile wait for the next run.
- Code workflows run one at a time per workflow. A slot that comes due while the previous run is still going waits in the queue behind it. Different workflows run side by side.
- A hub-wide cap is optional. Set
max_concurrent_workflows: Ninworkflows/settings.yamlto limit how many code workflows run at once. The engine reads it at start. There is no cap by default. - Markdown tasks and code workflows use separate queues, so one of each can run at the same time.
Delivery semantics
Code workflows run at least once
- A completed step is not run again when a run resumes. Its saved result is used instead.
- A step that was running when the process stopped runs again. Side effects inside a step can therefore happen twice.
- A step is tried once unless you set
@step(max_attempts=N). hub.call_llmhas no side effects and is retried once.hub.call_agentruns tools, so it is retried only whenworkflows/settings.yamlsetsagent_max_attempts.hub.publish_artifactandhub.send_emailare recorded steps. A resumed run gets back the artifact it already published and never sends an accepted email again. A send interrupted mid-transfer is reported asambiguousand never resent automatically.- A
hub.call_jevcall still in flight when the process stopped is made again on resume, so it can be billed twice. - A write to
hub.statecommits immediately and is not part of the checkpoint. A read, modify and write counter such ashub.state["n"] = hub.state.get("n", 0) + 1can double count when a run is replayed.
Make every step that changes something outside the hub safe to repeat:
- Use a key the other system deduplicates on, such as a record id or the run's slot, instead of creating a new record each time.
- Check before you write: does the ticket for this alert already exist?
- Record what you finished as a per-item marker, for example
hub.state[f"done:{item_id}"] = True, and skip marked items.
Markdown tasks never repeat their work
- The work step is never repeated. If the process stops mid-work, the recovered run fails with an error that starts
interrupted by a restart. Progress carries forward through the task's state file. - A run that finished its work but stopped before commit or push resumes from that step.
- The push step is retried once. A rebase conflict fails the run and leaves the commit local for a person to resolve.
- Webhook events are archived only when a run finishes
DONE. A failed or incomplete run leaves them pending, and the task runs again once that run has ended. Handling is at least once, so keep a record of handled events in the state file.
Missed runs and catch-up
The two kinds treat time they could not run differently.
| Situation | Markdown task | Code workflow |
|---|---|---|
| The hub was down across one or more slots | Runs once on the first tick after start. The skipped slots are counted. | Not backfilled. The skipped slots are counted and the Console warns about the gap. |
| A new file appears | Anchors at discovery and first runs at its next future match | Loads when the hub next starts, then runs at its next slot |
| Paused, then resumed | Runs once if a slot passed while paused | Runs at its next slot |
| A backup held new runs | Runs once when the hold ends | The latest slot inside the hold runs when it ends. Earlier ones are counted as missed |
| The dispatcher fell behind by several slots | Covered by the single catch-up run | Only the latest slot runs. Earlier ones are counted as missed |
A markdown task computes its next run from its last fire (or from when it was first seen). That is why downtime produces one catch-up run instead of one run per missed slot. The count of slots the catch-up stood in for is kept for 31 days in the task's missed_log in .hubzoid/schedule-state.json. Code workflow misses are kept for 31 days in the hub's runtime health. Each agent's Runs & schedules tab shows missed code workflow runs beside the scheduler's last dispatch, and the Console's summary API (/portal/api/summary) adds both kinds into a missed-slots figure per agent for the chosen period.
When workflow code changes
Runs belong to the code that started them. The code version is a hash of the installed Hubzoid version and every workflows/**/*.py file in the hub. The engine resumes an interrupted run only under the same version.
When the hub starts under a new version, runs that were queued or interrupted under the previous code are cancelled, so they cannot hold a one-at-a-time queue. They stay in the run list as CANCELLED. A markdown run that was queued but had not started is queued again under the new code first, with :requeued added to its id, so its slot is not lost.
Editing a markdown task file, workflows/settings.yaml, knowledge or configuration does not change the version. Code a workflow imports from outside workflows/ is not part of the hash. Upgrading Hubzoid does change it. Before you deploy a change, drain queued runs as described in Operating runs.
Edits reach runs at different times:
- The scheduler re-reads
schedule/*.mdon every tick, so markdown task edits apply without a restart. workflows/settings.yamlis read at the start of each code workflow run.- Code workflow modules load when the hub starts. Restart the hub to pick up a code change.
How runs appear in the Console
Runs and schedules live inside each agent. An agent's Runs & schedules tab lists its code workflows and markdown tasks (named md:<task>) with their schedule, state, Runs as and next run. Open a workflow to list its runs, and open a run to see its timing, its steps and the account it ran as.
A run's result belongs to the account it acted as. The hub's managers see each run's workflow, status, timing and a failure summary. The result, step outputs and detailed errors are shown only to that account, except for a legacy service run, which acts for no person. hubzoid schedule status on the server shows everything. Existing links to the cross-agent run list from earlier releases still open it. Run controls stay on the server command line, and the Console records every pause, resume and cancel in Activity.
See Console runs for the screens and Operating runs for the commands.
Who a run acts as
Every scheduled run acts as an ordinary Hubzoid account: a person, or an account your team creates for shared automation, such as reports@company.com. There is no separate service account type. The first match wins:
run_ason the declaration:@workflow(..., run_as="priya@company.com"), orrun_as:in a markdown task's frontmatter.HUBZOID_WORKFLOW_USERin the hub's configuration: a hub secret, else<hub>/.env.HUBZOID_WORKFLOW_USERin the deployment's configuration: the deployment secret or the gateway's environment.- On a hub whose access is managed in the Console, the owner recorded at setup. That is the configured initial owner (
HUBZOID_GATEWAY_ADMIN_EMAILorWEBUI_ADMIN_EMAIL), recorded the first time it signs in. A local quickstart with sign-in off usesadmin@localhost.
hubzoid schedule list and the Runs as column in the Console use this same resolution and say where the account came from. run_as chooses whose permissions a run uses and grants nothing. It is read only from files and operator configuration, never from an API, a tool or a model.
The account must be usable: signed in at least once, not awaiting approval, blocked or replaced, and on a Console-managed hub it must hold Use this agent. Otherwise the run fails with an error that names the fix. Hubzoid never falls back to another account. The account is checked again before every model or agent call, tool call, publish, email and connection, so a run whose account is blocked stops at its next call.
What the run touches belongs to that account:
| Belongs to | |
|---|---|
| Restricted tools | The account's own grants, checked at every call. Restricted tools in scheduled runs need a Console-managed hub. |
hub.state | The run's account. The same workflow run for someone else starts empty. |
hub.shared_state | Everyone who runs the workflow. Keep personal data out of it. |
hub.run_dir | This run only, a private folder under .hubzoid/runs/ that agent file tools cannot read. |
| A markdown task's state folder | The run's account, .hubzoid/schedule/<task>@<person>/. |
| Artifacts and email | The run's account. hub.send_email can reach no one else. |
| Personal connections | The account's own Open WebUI connections, used through hub.call_agent. Never the author's or an administrator's. |
State written before 1.0.1, in hub.state or a markdown task's .hubzoid/schedule/<task>/ folder, is kept as it was and belongs to no one, so a person's state starts empty. To carry a markdown task's progress over, copy its old state.json into the person's folder before their first run. Keep state that must survive a change of account in hub.shared_state.
Before 1.0.1, a code workflow acted as the service identity workflow:<name> and a markdown task as workflow:md:<task>. Those identities are legacy. Their grants stay, labelled Legacy service identity in the Console, but a run that acts as an account does not use them. When the account lacks a permission the old identity held, the run log and the server log name it, so you can grant it to the account. New workflow:* identities cannot be added in the Console. A legacy hub, whose access is still managed in the chat app, switches only when you set run_as or HUBZOID_WORKFLOW_USER. Until then its runs keep their service identity, state and scratch folder, with a warning, and cannot publish, send email or use personal connections. See Identity and access.
Next steps
Markdown tasks
Write a scheduled task in plain language, test it and let it commit its results.
Code workflows
Build exact, durable steps in Python using the Watchtower sample.
Webhooks
Run a task when an alerting system, CI or another service posts an event.
Operating runs
Pause, resume, cancel, drain and troubleshoot scheduled work.
Memory and history
How a Hubzoid Hub keeps authored knowledge, conversation history per surface, workflow state per account and learned knowledge saved by the remember tool.
Identity and access
How Hubzoid decides who may open an agent and call each restricted tool, from subjects and permissions to surfaces, decision logs and fail-closed rules.
