Markdown tasks
Write a scheduled markdown task in schedule/, test it with a dry run, choose the account it runs as, and let Hubzoid run it through rounds, state files, scoped git commits and optional artifacts and email.
A markdown task is one file under schedule/ in the hub. The frontmatter says when it runs, which paths it may change and, optionally, which account it runs as. The body is instructions that the hub's own agent carries out unattended, with the same AGENTS.md, skills, knowledge, tools and model as chat.
When to use this
Use a markdown task for recurring work you can describe in a paragraph: a weekly summary, a nightly reconciliation, keeping knowledge in step with a repository. Use a code workflow when the steps must be exact, and a webhook trigger when an outside system should start the work.
Write a task end to end
The example is a home goods retailer with four stock locations. Each night a count export lands in raw_data/counts/. On Monday morning a task writes a summary of the week.
Create the task file
The filename stem is the task name, here weekly-count-summary. Names start with a letter or digit and use only letters, digits, ., _ and -.
---
schedule: "17 7 * * 1" # Mondays at 07:17, server local time
timeout: 900 # seconds per round
max_rounds: 3
write: ["output/counts/"] # writable, not committed, while you test
---
Summarize last week's stock counts for the four store locations.
- Call `current_time` for today's date. The report file is
`output/counts/week-<YYYY-MM-DD>.md`.
- Your state file records the date of the last report. If this week's
report is already recorded, report DONE.
- List the exports under `raw_data/counts/` with `list_files` and read the
ones from the last 7 days with `read_file`.
- For each location, write the net count change and the five items with
the largest change. Name any location with no export this week at the top.
- Save the report with `write_hub_file`, then record today's date in the
state file.
- You are done when the report is written and the state file is updated.Write the body around the state file ("your state file records X"). It is the only memory a task has between rounds and between runs.
Check it without a model call
hubzoid doctor my-hub # schedule.tasks: every file parses
hubzoid schedule list my-hub # cadence, next fire, last result
hubzoid schedule run my-hub weekly-count-summary --dry-run--dry-run prints the exact round 1 prompt the agent receives and exits. Nothing is queued and no model is called.
Run it once with tight caps
hubzoid schedule run my-hub weekly-count-summary --timeout 300 --max-rounds 2The command queues the task on the hub's engine, waits for it and prints the run log path. It exits 0 when the agent reported DONE and 1 otherwise. Read the report under output/counts/ and adjust the wording of the body until the result is right.
Commit the results
Once you trust the output, change write: to commit: so Hubzoid commits the report after each successful run, and add push: true to push the commit.
commit: ["output/counts/"]
push: trueLet it run
Nothing else is needed. The process that runs hubzoid run my-hub runs the schedule. The scheduler starts only when the hub starts with at least one enabled task or scheduled eval, so restart the hub after you add its first task.
Frontmatter
Every task needs exactly one trigger, schedule: or on_webhook:. An agent task also needs a non-empty body. Keys not listed here are ignored.
Prop
Type
A file is rejected when a trigger is missing or doubled, the cron has the wrong field count or never matches a real date, an integer is not positive, a path is absolute or contains .., push: true has no commit: paths, model: is empty, a run: list contains unquoted values, run_as: is not an email, publish_artifacts: or send_email: is not true or false or is set on a run: task, or an agent task has no body. A rejected file is skipped and every other task keeps running. hubzoid schedule list prints the problem and exits 1, hubzoid doctor fails schedule.tasks, and the Console lists it as an error. Files named README.md or starting with . are not tasks.
Cron and time
| Field | Values |
|---|---|
| Minute | 0 to 59 |
| Hour | 0 to 23 |
| Day of month | 1 to 31 |
| Month | 1 to 12 |
| Day of week | 0 to 7, where 0 and 7 are Sunday |
Each field accepts *, */N, a value, a range A-B, a stepped range A-B/N or a comma list of these. Values are numbers only, so write 1 for Monday, not MON. When both day of month and day of week are restricted, a day matches if either one matches, as in standard cron.
- Tasks run in the local time of the machine that runs the hub. In a container, that is the container's local time.
- The scheduler checks every 30 seconds and re-reads
schedule/*.mdeach time, so edits apply without a restart. - A new task anchors when the scheduler first sees it and first runs at its next future match. Use
hubzoid schedule runto try it at once. - After downtime, a task that missed one or more matches runs once on the first tick, not once per missed match. The number of slots it stood in for goes into its
missed_log, kept for 31 days. - A due task waits while the hub is answering chat and starts on a later tick.
- Markdown tasks run one at a time per hub.
Pick an off-minute such as 7 3 rather than 0 3. It keeps your task apart from the many jobs that start on the hour and makes logs easier to correlate.
How a run works
Each run is a loop of rounds. A round is one fresh agent session that receives a harness preamble, your body, and a carry note from the previous round. Fresh contexts let a large backlog be chunked across rounds instead of overflowing one session.
The preamble tells the agent that it runs unattended, where its state file is, which paths it may write, that failed tool results are failures, never to regress its state file, and its time budget. It ends with the finish protocol. The agent must end every round with exactly one of these lines:
STATUS: DONE — <one-line summary of what changed>
STATUS: CONTINUE — <what remains>Hubzoid matches that line, case-insensitively, and the last one in the reply wins. A colon or hyphen after DONE also parses. Nothing else ends a run early.
| Round ends with | What happens |
|---|---|
STATUS: DONE | The run ends as done. Its summary becomes the commit message when the task commits. |
STATUS: CONTINUE | The next round starts with the note as its carry. |
| No status line | The next round starts and is told to check the state file. |
The round timeout | The round is cut off and the next round resumes from the state file. |
| A backend error | The next round starts. Three failed rounds in a row end the run as error. |
max_rounds reached | The run ends as incomplete. The next scheduled run resumes from the state file. |
A run ends as done, incomplete or error. Only error marks the run as failed in the Console. An incomplete run shows as succeeded, with incomplete in its result, and hubzoid schedule run exits 1 for it.
State and log files
| Path | Written by | Holds |
|---|---|---|
.hubzoid/schedule/<task>@<person>/state.json | The agent, with write_hub_file | The task's progress for the account the run acts as. The only continuity between rounds and runs. |
.hubzoid/schedule/<task>@<person>/runs/<YYYYMMDDTHHMMSS>.jsonl | Hubzoid | One JSON line per event: run and round boundaries with the account the run acts as, each run_git, write_hub_file, publish_artifact and send_email call, the agent's full reply per round, the parsed status and errors with tracebacks. Flushed per line, so tail -f follows a live run. |
.hubzoid/schedule-state.json | Hubzoid | Per task: when it was first seen, when it last fired, the last result, the last run log and its missed_log. |
<person> is a folder name derived from the account's email, so the same task run for two people never shares state. The run's own folder is always writable by the task. A folder from before 1.0.1, .hubzoid/schedule/<task>/, is kept as it was and used only by a legacy hub with no account configured. To carry its state over, copy state.json into the person's folder before their first run. Keep .hubzoid/ out of git. It is runtime state, not content.
Tools during a run
The agent has the hub's usual tools (read_file, list_files, grep_data, knowledge and skill tools, hub tools and MCP servers) plus two that exist only in scheduled runs:
| Tool | What it allows |
|---|---|
run_git(repo, args) | Read and sync git in a checkout inside the hub: pull, fetch, log, diff, show, status, rev-parse, ls-files, branch, shortlog, describe and read-only remote. Other verbs, and flags that write files or run commands, are refused. |
write_hub_file(path, content) | Create or overwrite a file under the task's write: and commit: paths or its own scratch folder. Anything else is refused. The schedule/ folder and any .git/ directory are refused even when a declared path covers them. |
publish_artifact(path, title) | Only with publish_artifacts: true. Publish a file under the task's writable paths as a private artifact owned by the account the run acts as, and return its link. |
send_email(subject, body, artifact_ids) | Only with send_email: true. Email the account the run acts as, optionally linking artifacts published in the run. There is no other recipient, and a run can send at most 5. |
The agent cannot commit or push. Hubzoid does that itself after the run.
Publish and email results
A task opts in to delivery in its frontmatter. The model cannot turn these tools on, and cannot change the owner or the recipient.
---
schedule: "7 7 * * 1-5"
run_as: priya@company.com
publish_artifacts: true
send_email: true
---
Write today's digest as an HTML file in your scratch folder, publish it,
and email me a link.The artifact is private to that account until they share it from its viewer. Email uses the deployment's SMTP settings (HUBZOID_SMTP_*), or writes to a preview outbox with HUBZOID_EMAIL_DELIVERY=preview. The tool reports the delivery status. Only accepted means the SMTP server took the message, and previewed means it was written to the outbox and not sent. Tasks without these keys are offered nothing new.
Write, commit and push
write:paths can be changed but are never committed. Use them while testing, then review the diff by hand.commit:paths are writable too. After adonerun, Hubzoid stages and commits exactly those paths with the messageschedule(<task>): <summary>. Changes elsewhere in the working tree, such as source clones underraw_data/or local edits, are never swept in.- A run that changed none of its
commit:paths makes no commit and pushes nothing. - With
push: true, Hubzoid runsgit pull --rebaseand thengit push, and retries the push once. A rebase conflict is aborted cleanly: the run is recorded aserror, the commit stays local and nothing is pushed.
The hub can be the root of a repository or a folder inside a larger one. Set up the server once: the repository needs a remote and a tracking branch, git config user.name and user.email, and credentials that work without a prompt, such as a deploy key.
Run a command instead of the agent
A task with run: executes a command in the hub folder and does not call the model. It shares the same schedule, queue, log, state and commit steps.
---
schedule: "41 1 * * *"
run: ["python", "scripts/export_counts.py", "--days", "1"]
timeout: 600
commit: ["output/exports/"]
---
Writes yesterday's count export. Documentation only.A string such as run: "python scripts/export.py | gzip > output/exports/latest.gz" runs through the shell, so pipes and && work. A list runs without a shell. Exit code 0 is done and any other code is error. When timeout passes, the whole process group is stopped. The log keeps the last 4,000 characters of standard output and standard error. The script finds the account the run acts as in HUBZOID_RUN_AS, for information only. It gains no permission from it.
Run once with hubzoid schedule run
hubzoid schedule run <hub> <task> [--timeout SECONDS] [--max-rounds N] [--model ID] [--dry-run]Prop
Type
A manual run ignores the cron and the chat gate. It is queued on the same one-at-a-time queue as scheduled runs, so it waits for a running one and never overlaps it. It uses the hub's configured model and appears in the run history with an id such as md:weekly-count-summary:manual-20260925T101500. A manual run of a webhook task claims no events. Exit codes: 0 done, 1 incomplete or failed, 2 no such task.
Enable, disable and pause
| To | Do this |
|---|---|
| Keep a task but stop its schedule | Set enabled: false. The change applies on the next tick. |
| Stop a task without editing the file | hubzoid schedule pause <hub> <task>, recorded in the access audit. resume runs it once if a slot passed. |
| Stop every markdown task on a server | Set HUBZOID_DISABLE_SCHEDULE=1 in its environment and restart the hub. Scheduled evals stop too. |
| Start a task now | hubzoid schedule run <hub> <task>, which works for disabled and paused tasks too. |
See Operating runs for pause, resume, cancel and troubleshooting.
Access
A markdown task runs as an ordinary account: run_as:, else HUBZOID_WORKFLOW_USER, else the owner recorded at setup. The resolution and its rules are in Workflows and schedules. hubzoid schedule list and the Runs as column in the Console show the account, or why the task cannot run. A run fails, with the fix in its error, when the account is missing or cannot be used, and never falls back to another person.
The task reaches a restricted tool only when that account holds the tool's permission, on a hub whose access is managed in the Console. Grant it with Edit access in the Console, or on the command line:
hubzoid grant priya@company.com <permission> my-hubGrants made to the legacy identity workflow:md:<task> before 1.0.1 stay but are not used. A legacy hub with no account configured keeps running the task as workflow:md:<task>. Each round writes a usage row for the run's account, so its tokens and estimated cost appear on the agent's card on the Console's Agents page. See Restrict tools.
Next steps
Webhooks
Trigger a markdown task when an outside system posts an event.
Code workflows
Write exact, durable steps in Python when instructions are not enough.
Operating runs
Pause, resume, cancel and troubleshoot scheduled work.
Workflows and schedules
How both kinds share one engine, and how retries and catch-up work.
Evals
Check that a Hub keeps doing its job with Markdown eval cases, free checks, a model judge, scheduled runs, CI gates and optional Langfuse history.
Code workflows
Build a durable Python workflow with @workflow, @step and hub from the packaged Watchtower sample, choose the account it runs as, publish and email a report, and enable its schedule on the server that should run it.
