---
title: Markdown tasks
description: 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.
canonical_url: https://hubzoid.com/docs/guides/markdown-tasks
last_updated: 2026-09-27
---

# 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](https://hubzoid.com/docs/guides/code-workflows) when the steps must be exact, and a [webhook trigger](https://hubzoid.com/docs/guides/webhooks) 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.

1. **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 `-`.

   ```markdown title="schedule/weekly-count-summary.md"
   ---
   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.

2. **Check it without a model call**

   ```bash
   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.

3. **Run it once with tight caps**

   ```bash
   hubzoid schedule run my-hub weekly-count-summary --timeout 300 --max-rounds 2
   ```

   The 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.

4. **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.

   ```yaml title="schedule/weekly-count-summary.md (frontmatter)"
   commit: ["output/counts/"]
   push: true
   ```

5. **Let 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.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `schedule` | `string` |  | A numeric 5-field cron, evaluated in the local time of the machine running the hub. Quote it in YAML. Required unless on\_webhook is set. |
| `on_webhook` | `string \| true` |  | Runs the task while events from this generic webhook endpoint wait in its inbox. true means the default endpoint name, webhook. Cannot be combined with schedule. |
| `run` | `string \| list[string]` |  | Runs a command instead of the agent. A string runs through the shell. A list of quoted strings runs as argv with no shell. The body becomes optional documentation. |
| `enabled` | `boolean` | `true` | false keeps the file but stops scheduled runs. A manual run still works. |
| `timeout` | `integer` | `1800` | Seconds per round for an agent task. For a run task, the limit for the whole command. |
| `max_rounds` | `integer` | `10` | Fresh-context rounds per run before the run ends incomplete. |
| `max_turns` | `integer` | `40` | Agent turns within one round. |
| `write` | `string \| list[string]` | `[]` | Hub-relative paths the run may change but that are not committed. Use while testing. |
| `commit` | `string \| list[string]` | `[]` | Hub-relative paths the run may change and that Hubzoid commits after a successful run. |
| `push` | `boolean` | `false` | After the commit, git pull --rebase then git push. Requires commit. |
| `model` | `string` | `the hub model` | Model for this task instead of the hub model, for agent tasks. |
| `run_as` | `string` | `HUBZOID_WORKFLOW_USER, else the owner recorded at setup` | Email of the account every run acts as. It chooses whose permissions, state, artifacts and email the run uses, and grants nothing. |
| `publish_artifacts` | `boolean` | `false` | Offers the agent a publish\_artifact tool for files under the task's writable paths. Agent tasks only. |
| `send_email` | `boolean` | `false` | Offers the agent a send\_email tool that emails the run's account and nobody else, at most 5 times per run. Agent tasks only. |

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/*.md` each 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 run` to 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:

```text
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.

```markdown title="schedule/weekday-digest.md"
---
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 a `done` run, Hubzoid stages and commits exactly those paths with the message `schedule(<task>): <summary>`. Changes elsewhere in the working tree, such as source clones under `raw_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 runs `git pull --rebase` and then `git push`, and retries the push once. A rebase conflict is aborted cleanly: the run is recorded as `error`, 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.

```markdown title="schedule/nightly-export.md"
---
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

```bash
hubzoid schedule run <hub> <task> [--timeout SECONDS] [--max-rounds N] [--model ID] [--dry-run]
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `--timeout` | `integer` |  | Seconds per round for this run instead of the task value. |
| `--max-rounds` | `integer` |  | Round cap for this run instead of the task value. |
| `--model` | `string` |  | Model for this run. Ignored for run tasks. |
| `--dry-run` | `flag` |  | Print the round 1 prompt, or the command of a run task, and exit. |

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](https://hubzoid.com/docs/guides/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](https://hubzoid.com/docs/concepts/workflows-and-schedules#who-a-run-acts-as). `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:

```bash
hubzoid grant priya@company.com <permission> my-hub
```

Grants 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](https://hubzoid.com/docs/guides/restrict-tools).

## Next steps

- [Webhooks](https://hubzoid.com/docs/guides/webhooks): Trigger a markdown task when an outside system posts an event.
- [Code workflows](https://hubzoid.com/docs/guides/code-workflows): Write exact, durable steps in Python when instructions are not enough.
- [Operating runs](https://hubzoid.com/docs/guides/operating-runs): Pause, resume, cancel and troubleshoot scheduled work.
- [Workflows and schedules](https://hubzoid.com/docs/concepts/workflows-and-schedules): How both kinds share one engine, and how retries and catch-up work.
