---
title: Workflow API
description: Reference for Hubzoid code workflows. The @workflow and @step decorators, the hub object, the account a run acts as, workflow state, artifacts, email, the schedule grammar, settings and exceptions.
canonical_url: https://hubzoid.com/docs/reference/workflow-api
last_updated: 2026-09-27
---

# Workflow API

Reference for Hubzoid code workflows. The @workflow and @step decorators, the hub object, the account a run acts as, workflow state, artifacts, email, the schedule grammar, settings and exceptions.

This page lists every public part of the code workflow API in Hubzoid 1.0.1. For a guided introduction, start with [Code workflows](https://hubzoid.com/docs/guides/code-workflows). For markdown tasks, see [Markdown tasks](https://hubzoid.com/docs/guides/markdown-tasks).

## Imports

```python
from hubzoid import workflow, step, hub
```

`import hubzoid` does not import the workflow engine. `workflow`, `step` and `hub` are loaded on first access. Exceptions are imported from their own modules:

```python
from hubzoid.workflows.schedule_grammar import ScheduleError
from hubzoid.structured import ModelOutputError
from hubzoid.runtime import AgentRunError
from hubzoid.workflows.identity import IdentityError
from hubzoid.email_delivery import EmailError
from hubzoid.jev import JevError
```

Hubzoid imports workflow modules itself, after it starts the hub's engine: when the hub starts, and when `hubzoid schedule run` runs a workflow. It loads every `.py` file in each folder directly under `workflows/` and skips files whose names start with `_`. The decorators need the engine, so importing a workflow file in plain Python raises `RuntimeError`.

## @workflow

```python
workflow(schedule: str | None = None, *, timezone: str | None = None, on_failure: str | None = None,
         run_as: str | None = None)
```

Declares a durable workflow. The decorated function takes no parameters.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `schedule` | `str \| None` | `None` | A schedule phrase or a raw 5-field cron (see Schedule grammar). None declares a manual workflow that runs only with hubzoid schedule run. May be passed positionally. |
| `timezone` | `str \| None` | `None` | IANA timezone name the schedule is evaluated in. None means UTC. |
| `on_failure` | `str \| None` | `None` | A URL starting with http\:// or https\:// receives a POST when a run raises. Any other value writes an error line naming it to the server log. |
| `run_as` | `str \| None` | `None` | Email of the account every run acts as. None uses HUBZOID\_WORKFLOW\_USER from the hub, then the deployment, then the owner recorded at setup on a Console-managed hub. A value that is not an email raises ValueError when the module loads. It grants nothing. |

- The workflow name is the function's `__name__`. A second workflow with the same name in the hub raises `ValueError`.
- The schedule and timezone are validated when the module loads. An unparseable schedule raises `ScheduleError`. An unknown timezone raises the standard library's `ZoneInfoNotFoundError`.
- Always call the decorator. Use `@workflow()` for a manual workflow.
- Write `schedule`, `timezone` and `run_as` as string literals. The Console, `hubzoid doctor` and `hubzoid schedule list` read them from the source without running it.
- Each run first resolves the account it acts as and records it as the step `hz_run_identity`. A resumed run reuses it. Without a usable account the run fails with `IdentityError` and never falls back to another account. See [Workflows and schedules](https://hubzoid.com/docs/concepts/workflows-and-schedules#who-a-run-acts-as).
- Each run binds `hub` to the run, with that account on the `workflow` surface and the settings from `workflows/settings.yaml`.
- The return value becomes the run's result. Return plain data such as a dict. The Console shows the first 8,000 characters of a result or error to the account the run acted as. The hub's managers see a failure summary instead.
- An exception that leaves the function marks the run failed. With `on_failure` set, Hubzoid sends the notification first and then re-raises, so the failure is never hidden.

The `on_failure` POST has a 10 second timeout and this JSON body:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `workflow` | `str` |  | The workflow name. |
| `hub` | `str` |  | The hub name. |
| `error` | `str` |  | The exception message. |

## @step

```python
step(fn=None, *, max_attempts: int = 1)
```

Marks a function as a durable step. Use it bare (`@step`), called (`@step()`) or with options (`@step(max_attempts=3)`).

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `max_attempts` | `int` | `1` | Total attempts when the step raises. 1 means no retry. Set it above 1 only for steps that are safe to repeat. |

- A completed step's result is saved. When a run resumes after a restart, the saved result is returned and the function is not called again.
- A step that was running when the process stopped runs again, so delivery is at least once. Make side effects idempotent.
- The result is stored in the run history, and the Console shows the first 4,000 characters of each step's output or error to the account the run acted as. Do not return secrets, because `hubzoid schedule status` on the server shows every result.
- Read secrets with `hub.secret` inside the step that uses them rather than passing them in.

## hub

`hub` is a proxy bound to the current run, so concurrent runs never see each other's context. Outside a running workflow, its members raise `RuntimeError`, except `secret`, which only reads the environment.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `str` |  | The current hub name, which is the hub folder name. |
| `setting(key, default=None)` | `Any` |  | The value of a top-level key in workflows/settings.yaml, or default. The file is read at the start of each run. |
| `secret(key, default=None)` | `str \| None` |  | The environment variable named key in upper case, or default. The environment includes the hub .env and restricted/.env. |
| `state` | `WorkflowState` |  | Durable key and value storage for this workflow and the run's account. See hub.state. |
| `shared_state` | `WorkflowState` |  | Durable storage shared by every account that runs this workflow. See hub.state. |
| `run_dir` | `Path` |  | A private folder for this run. See hub.run\_dir. |
| `user` | `HubUser` |  | The account the run acts as. See hub.user. |
| `call_llm(prompt, ...)` | `str \| dict \| BaseModel` |  | One model call with no tools, saved as a step. See hub.call\_llm. |
| `call_agent(task, ...)` | `str \| BaseModel` |  | The hub's full agent with its tools, saved as a step. See hub.call\_agent. |
| `call_jev(state, questions, ...)` | `dict` |  | Experimental. Typed decisions from Jev, saved as a step. See hub.call\_jev. |
| `publish_artifact(path, ...)` | `dict` |  | Publishes a file as a private artifact owned by the run's account, saved as a step. See hub.publish\_artifact. |
| `send_email(subject, ...)` | `dict` |  | Emails the run's own account, saved as a step. See hub.send\_email. |

Every model, agent, Jev, publish and email call first checks that the run's account is still usable, so a run whose account was blocked stops at its next call.

### hub.call\_llm

```python
hub.call_llm(prompt: str, *, response_format: str = "text", response_model=None,
             model: str | None = None, system: str | None = None)
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `prompt` | `str` | Required | The user message. |
| `response_format` | `"text" \| "json"` | `"text"` | text returns a string. json returns the parsed reply as a dict. Any other value raises ValueError. |
| `response_model` | `type[pydantic.BaseModel] \| None` | `None` | Asks for JSON matching the model schema and returns a validated instance. Implies response\_format json. |
| `model` | `str \| None` | `None` | Model for this call. Any LiteLLM model id, claude-local or codex-local. None uses the hub model. |
| `system` | `str \| None` | `None` | System instructions for this call. |

| Called with | Returns |
| --- | --- |
| Neither `response_format="json"` nor `response_model` | `str`, the reply text |
| `response_format="json"` | `dict`. A reply that is valid JSON but not an object raises `ModelOutputError` |
| `response_model=Model` | An instance of `Model`, validated with Pydantic |

- For JSON, Hubzoid appends an instruction to reply with only a JSON object, with the schema when `response_model` is given. LiteLLM models also get the provider's JSON mode. `claude-local` and `codex-local` run one turn with no tools.
- The reply is parsed from the whole text, then a fenced code block, then the outermost braces or brackets. A reply with no valid JSON, or one that fails validation, raises `ModelOutputError`.
- The call is a step that is tried twice in total, because it has no side effects. A completed call is not repeated when a run resumes.
- Each call writes a usage row with the run's account, the model, tokens, estimated cost and duration.

### hub.call\_agent

```python
hub.call_agent(task: str, *, response_model=None)
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `task` | `str` | Required | The instruction for the agent. |
| `response_model` | `type[pydantic.BaseModel] \| None` | `None` | Asks the agent to finish its reply with JSON matching the model schema and returns a validated instance. |

- Runs the hub's full agent, with its tools, skills, knowledge and model, as the run's account on the `workflow` surface. Restricted tools need that account's grant, and personal MCP tools come from that account's own Open WebUI connections, never the author's or an administrator's.
- Returns the agent's reply as `str`, or an instance of `response_model`.
- A failed agent run raises `AgentRunError`. JSON that is missing or does not validate raises `ModelOutputError`.
- The call is a step. It is tried once unless `agent_max_attempts` in `workflows/settings.yaml` is above 1, because a retry can repeat a message or a write the first attempt made.
- Each call writes a usage row.

### hub.call\_jev

```python
hub.call_jev(state, questions: dict, *, model: str = "typesafe/jev-1.13") -> dict
```

Experimental. Asks TypeSafe's Jev, a decision model, through OpenRouter's Decisions API, which is in alpha, so its shapes may change.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `str \| dict \| list` | Required | What the questions are about. |
| `questions` | `dict` | Required | Question name to a question with only type, instructions and criteria. type is noul (does this hold), choice (which label) or score (where on an ordered scale). One request may mix types. |
| `model` | `str` | `"typesafe/jev-1.13"` | The Jev model id. |

- Returns a dict of answers by question name. A `noul` answer holds the probability of yes, a `choice` answer one of your labels with probabilities, and a `score` answer a value within the scale with probabilities. Every answer is checked against its question.
- Needs `JEV_OPENROUTER_API_KEY`, a dedicated OpenRouter key. It never falls back to `OPENROUTER_API_KEY` or the hub model.
- Failures raise `JevError`: a missing or rejected key, missing credits, invalid questions, or an empty or malformed reply. Rate limits, server errors and timeouts are retried once.
- The call is a step. A completed call is not repeated on resume, but one still in flight when the process stopped is made again, so a call is at least once. Each call writes one usage row.

### hub.publish\_artifact

```python
hub.publish_artifact(path, *, title: str | None = None, audience: str = "owner", share_with=()) -> dict
```

Publishes an existing file as a new artifact owned by the run's account. The owner cannot be chosen by the caller.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | `str \| Path` | Required | The file to publish, such as one under hub.run\_dir. A relative path resolves against the hub folder. |
| `title` | `str \| None` | `None` | Title shown in the viewer. None uses the file name without its extension. |
| `audience` | `"owner" \| "hub" \| "people"` | `"owner"` | owner keeps it private. hub shares it with everyone who can use the agent. people shares it with share\_with. hub and people need a Console-managed hub. |
| `share_with` | `list` | `()` | For audience people. Account emails, or \{"kind": "group", "principal": name} for a group. Each person must be able to use the agent. |

- Returns `id`, `url`, `title`, `filename`, `content_type`, `size` and `audience`. `url` is the viewer, `https://<host>/portal/artifacts/<id>`, which asks the person to sign in.
- Every call stores a new artifact, so earlier ones are never overwritten. Files are kept under `.hubzoid/artifacts/`, which agent file tools cannot read. The largest file is `HUBZOID_ARTIFACT_MAX_BYTES`, 50 MiB by default.
- A workflow can never create a public link. The owner creates one in the viewer, and only with the **Share artifacts publicly** permission.
- The call is a step. A resumed run gets back the artifact it already published. It needs the run to act as a person, not a legacy service identity.

### hub.send\_email

```python
hub.send_email(subject: str, body: str = "", *, artifacts=(), raise_on_failure: bool = True) -> dict
```

Emails the run's own account. There is no recipient parameter, so neither code nor a model can address anyone else.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `subject` | `str` | Required | One line of at most 300 characters. |
| `body` | `str` | `""` | Plain text message. |
| `artifacts` | `list` | `()` | Artifacts the same account owns, as returned by hub.publish\_artifact or their ids. The email links to them, and opening a link needs a sign-in. Nothing is attached. |
| `raise_on_failure` | `bool` | `True` | Raise EmailError unless the result is accepted or previewed. False returns the result instead. |

- Returns `status`, `sent`, `delivery_id`, `recipient` and `message`. `status` is `accepted` (the SMTP server accepted the message, which does not confirm it reached the inbox), `previewed` (written to the outbox, not sent), `failed`, `ambiguous` (the connection ended while the server was receiving it) or `refused` (not tried, for example because email is not configured).
- It sends over SMTP with `HUBZOID_SMTP_HOST`, `HUBZOID_SMTP_FROM` and the other `HUBZOID_SMTP_*` settings, or writes to `.hubzoid/outbox/<person>/` with `HUBZOID_EMAIL_DELIVERY=preview`. See the [configuration reference](https://hubzoid.com/docs/reference/configuration).
- The call is a step. Failures before the message is transferred are retried. A resumed run never sends an accepted message again, and an `ambiguous` send is never resent automatically.

### hub.run\_dir

`hub.run_dir` returns a `Path` to a folder for this run only, `.hubzoid/runs/<workflow>/<run id>/` in the hub, created on first use. Agent file tools cannot read it. Write generated files there, then publish them.

### hub.state

`hub.state` returns a `WorkflowState` for the current hub, workflow and the account the run acts as. Values are stored as JSON in the table `hz_workflow_kv` of the deployment's operational database, keyed by hub, workflow, account and key, so two workflows never clash on a key and the same workflow run for someone else starts empty. State survives restarts and upgrades. `hub.shared_state` returns the same kind of object for state shared by every account that runs the workflow. Keep personal data out of it.

State written before 1.0.1 is kept but belongs to no account. Only a legacy hub with no account configured still reads it.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `get(key, default=None)` | `Any` |  | The stored value, or default when the key is absent. |
| `state[key]` | `Any` |  | The stored value. Raises KeyError when the key is absent. |
| `state[key] = value` | `None` |  | Stores a JSON-serializable value, replacing any earlier one. Committed immediately. |
| `key in state` | `bool` |  | Whether the key is stored. |
| `del state[key]` | `None` |  | Removes the key. Removing an absent key does nothing. |

Each write is its own database commit and is not part of a step's checkpoint. A read, modify and write sequence is therefore not atomic across a crash and replay, and a counter can double count. Store per-item markers, such as `hub.state[f"done:{item_id}"] = sha`, and skip items that are already marked.

### hub.user

`hub.user` returns a `HubUser` for the account the run acts as.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `str` |  | The account email. On a legacy hub with no account configured, the legacy service identity workflow:\<name>. |
| `email` | `str \| None` |  | The account email address. |
| `attrs` | `dict` |  | Per agent attributes of the account from the access store, for scoping the data a run reads to its person. An empty dict for a legacy service identity. |
| `can(permission, hub=None)` | `bool` |  | Whether the account holds permission in hub, or in the current hub when hub is None, at the moment of the call. Consults the access store. |

## workflows/settings.yaml

A YAML mapping at `workflows/settings.yaml`. A file that is not valid YAML or not a mapping fails each run that reads it.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `agent_max_attempts` | `int` | `1` | Total attempts for a failed hub.call\_agent. Read when the engine starts. |
| `max_concurrent_workflows` | `int` | `no cap` | Most code workflows running at once in this hub. Read when the engine starts. |
| `(any other key)` | `Any` |  | Your own settings, returned by hub.setting(key). Read at the start of each run. |

## Schedule grammar

Code workflow schedules are case-insensitive phrases or raw cron expressions. Hubzoid translates them to a 5-field cron and evaluates it in the workflow's timezone.

| Form | Examples | Cron |
| --- | --- | --- |
| Minute interval: `every N` with `minute`, `minutes`, `min` or `m` | `every 2 minutes`, `every 15 min`, `every 5m` | `*/N * * * *` |
| Hour interval: `every N` with `hour`, `hours`, `hr` or `h` | `every 1 hour`, `every 3 hours`, `every 6h` | `0 */N * * *` |
| Daily: `daily` or `every day`, optional `at`, then a time | `daily at 6am`, `daily 06:30`, `every day at 18:00` | `MM HH * * *` |
| Weekly: `every`, a day, optional `at`, then a time | `every monday 08:30`, `every fri at 5pm` | `MM HH * * D` |
| Raw cron: five fields of digits and `* / , -` | `30 6 * * 1-5` | Unchanged, after validation |

- A minute interval must divide 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30). An hour interval must divide 24 (1, 2, 3, 4, 6, 8 or 12).
- A time is `H:MM` or `HH:MM` on a 24-hour clock, or an hour with an optional `am` or `pm`, such as `6`, `6am`, `6 pm` or `18`. `12am` is midnight and `12pm` is noon. Minutes are written in the 24-hour form only.
- A day is `sunday` or `sun`, `monday` or `mon`, `tuesday` or `tue`, `wednesday` or `wed`, `thursday` or `thu`, `friday` or `fri`, `saturday` or `sat`. One day per phrase.
- A raw cron uses numbers only. For several weekdays or other patterns, use a raw cron such as `0 9 * * 1-5`.
- Anything else raises `ScheduleError` when the module loads.

The dispatcher wakes at the start of each minute and starts every workflow with a slot in the window since its last check. When several slots of one workflow fall in that window, only the latest runs and the others are counted as missed. Missed slots are never backfilled. Schedules fire only where `HUBZOID_SCHEDULES=1` is set or under `hubzoid gateway`.

## Exceptions

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `ScheduleError` | `ValueError` |  | hubzoid.workflows.schedule\_grammar. A schedule phrase or cron that cannot be parsed. |
| `ModelOutputError` | `ValueError` |  | hubzoid.structured. The model answered but not with the JSON asked for. The reply text is in .raw. |
| `AgentRunError` | `RuntimeError` |  | hubzoid.runtime. A hub.call\_agent run failed. |
| `KeyError` | `built-in` |  | hub.state\[key] for a key that is not stored. |
| `ValueError` | `built-in` |  | A duplicate workflow name, a run\_as that is not an email, or a response\_format other than text or json. |
| `IdentityError` | `RuntimeError` |  | hubzoid.workflows.identity. The run has no usable account. The message names the fix. |
| `JevError` | `RuntimeError` |  | hubzoid.jev. A hub.call\_jev call failed. |
| `EmailError` | `RuntimeError` |  | hubzoid.email\_delivery. hub.send\_email was not accepted or previewed. The delivery result is in .result. |
| `RuntimeError` | `built-in` |  | hub used outside a running workflow, or a decorator used before the engine started. |

## Runs

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `Run id` | `str` |  | Scheduled runs get an id derived from the hub, the workflow and the slot time, so a slot is queued once. Manual runs get an id generated by the engine. |
| `Status` | `str` |  | ENQUEUED, PENDING, SUCCESS, ERROR, CANCELLED or MAX\_RECOVERY\_ATTEMPTS\_EXCEEDED. |
| `Result` | `Any` |  | The value the workflow function returned. |
| `Runs as` | `str` |  | The account the run acts as on the workflow surface, recorded as the first step, hz\_run\_identity. Used for access checks, usage rows, the audit, and who may see the result in the Console. |
| `Concurrency` | `rule` |  | One run at a time per workflow. Different workflows run side by side, up to max\_concurrent\_workflows. |
| `Code version` | `str` |  | A hash of the Hubzoid version and every workflows/\*\*/\*.py file. Runs queued or interrupted under another version are cancelled when the hub starts. |

A markdown task run returns a result with this shape, shown as the run's result in the Console to the account the run acted as:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `result` | `"done" \| "incomplete" \| "error"` |  | How the work ended. A run with error is marked failed. |
| `rounds` | `int` |  | Rounds used, or 1 for a run task. |
| `summary` | `str` |  | The DONE summary, or ran followed by the command for a run task. |
| `error` | `str` |  | Why the run failed, when it did. |
| `run_log` | `str \| None` |  | Path of the JSONL run log. |
| `commit_sha` | `str \| None` |  | The commit made, present when the commit step ran. |
| `pushed` | `bool` |  | Present and true when the commit was pushed. |

A markdown task acts as an account in the same way, from `run_as:` in its frontmatter, and opts in to publishing and email with `publish_artifacts: true` and `send_email: true`. See [Markdown tasks](https://hubzoid.com/docs/guides/markdown-tasks#publish-and-email-results).

## Environment variables

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `HUBZOID_SCHEDULES` | `boolean` |  | Set to 1 or true to fire code workflow schedules in a single hubzoid run. Markdown tasks do not need it. |
| `HUBZOID_GATEWAY` | `boolean` |  | Set by hubzoid gateway for its bridges. Also enables code workflow schedules. |
| `HUBZOID_DISABLE_SCHEDULE` | `boolean` |  | Set to 1 or true to stop markdown tasks and scheduled evals on this process. |
| `HUBZOID_DBOS_DB` | `string` |  | Database URL for the engine run history. Default: .hubzoid/dbos.db in the hub, or a PostgreSQL DATABASE\_URL. |
| `HUBZOID_WEBHOOK_EVENTS` | `string` |  | Set for a run: script of a webhook task. The claimed event files, one absolute path per line. |
| `HUBZOID_WORKFLOW_USER` | `string` |  | The account email runs act as when a declaration has no run\_as. Read from the hub (a hub secret, else .env), then the deployment. |
| `HUBZOID_RUN_AS` | `string` |  | Set for a run: script. The account the run acts as, for information only. |
| `JEV_OPENROUTER_API_KEY` | `string` |  | The dedicated OpenRouter key for hub.call\_jev. |
| `HUBZOID_ARTIFACT_MAX_BYTES` | `integer` |  | Largest file hub.publish\_artifact accepts. Default 50 MiB. |
| `HUBZOID_EMAIL_DELIVERY` | `string` |  | smtp (default) sends through HUBZOID\_SMTP\_HOST and HUBZOID\_SMTP\_FROM. preview writes each email to the outbox and sends nothing. |

See the [configuration reference](https://hubzoid.com/docs/reference/configuration) for every variable and the [CLI reference](https://hubzoid.com/docs/reference/cli) for `hubzoid schedule` and `hubzoid new workflow`. The source is in [`hubzoid/workflows/`](https://github.com/hubzoid/hubzoid/blob/main/hubzoid/workflows/runtime.py).

## Next steps

- [Code workflows](https://hubzoid.com/docs/guides/code-workflows): Build a workflow step by step with the Watchtower sample.
- [Operating runs](https://hubzoid.com/docs/guides/operating-runs): Run, pause, resume, cancel and troubleshoot scheduled work.
- [Workflows and schedules](https://hubzoid.com/docs/concepts/workflows-and-schedules): Triggers, concurrency, delivery semantics and catch-up rules.
