Tasks over the API

Create a task from code, change it without overwriting anyone else's change, start it with input, and read how the run went.

What a task is, and what each part of it does, is on Tasks. This page is the same task as JSON. Creating, changing and starting a task needs a token scoped tasks:write; reading its runs needs runs:read. See Authentication.

A task as JSON

{
  "name": "Triage new issues",
  "teamId": "6f1d2c3e-8a4b-4c1e-9f0a-2b7d5e1c9a40",
  "assigneeRole": "support-engineer",
  "goal": "Every new issue is labelled and answered within a day.",
  "metric": { "text": "Share of new issues labelled within 24 hours" },
  "guardrails": ["Never close an issue.", "Never promise a release date."],
  "skillMd": "# Triage an issue\n\n1. Read the issue.\n2. Label it bug, feature or question.\n3. Reply once: thank the author and say what happens next.\n",
  "tools": ["github.issue_read", "github.issue_write", "github.add_issue_comment"],
  "gates": [{ "tool": "github.add_issue_comment", "owner": "ceo", "approval": "specific" }],
  "inputs": [
    { "key": "repo", "label": "Repository", "type": "string", "required": true },
    { "key": "issue", "label": "Issue number", "type": "number", "required": true }
  ],
  "spend": { "moneyUsdPerRun": 0.5, "maxModelTier": "mid" }
}

Only name is required. Everything else has a default, but a task with no assigneeRole has nobody to run it, and cannot start a run.

Field What it is
name Required. 1 to 200 characters.
slug Unique in your enterprise; webhooks and other tasks' successors find the task by it. Made from name when you leave it out. Either way it is cleaned: lower case, accents dropped, and every run of characters other than letters and digits turned into one -. At most 80 characters.
teamId The team it belongs to.
assigneeRole The slug of a role: each run goes to a member who holds that role on the task's team and is free, the one who has waited longest first. When the team has no role by that slug, an enterprise-wide role (one with no team) of that slug is used. A task cannot be given to one member. See Roles.
kind llm, the default, runs a model. deterministic runs none, and so may list no tools.
status active, the default, or deprecated. A deprecated task starts no new runs, from the API, its webhook, a schedule or the portal, and answers task_not_runnable. Runs already under way finish.
overlapPolicy skip, the default: a run waits while its member is busy with another. os: runs beside the member's other work, for a short check on a schedule that should never queue.
serializeOn The input keys that split the task's work into lanes, such as ["repo", "issue"]. Runs in different lanes run at once; two runs in the same lane never do. Left out or null, the task has one lane: one run at a time. At most 8 keys of letters, digits, _, . and -. See Runs at once.
goal One sentence: what the task is for.
metric { "text": "…" }: how you would know it works.
guardrails Rules the model must not break, one string each.
skillMd The skill, in Markdown: how to do the job. A heading with the slug until you write one.
tools Every tool a run may call. See Gates and tool scopes, and Built-in tools for the OS's own.
gates The calls that wait for you: tool, owner and approval. See Gates and tool scopes.
inputs The fields a run starts with: key, label, type (string or number), and optionally required, default and placeholder. The portal's Start a run form is built from them.
spend moneyUsdPerRun in US dollars, and a highest maxModelTier (economical, mid or frontier) or an exact model. See Spend and models.
workspace For a task that works in a repository: its branch, and which files it may write. See Workspaces.
successors What runs after it: each a slug, with optionally on, when, delayMinutes, maxRunsPerIssue and resumeConversation. See Handoffs.

Create a task

curl -X POST https://api.zerohuman.com/v1/tasks \
  -H "Authorization: Bearer zhos_…" \
  -H "content-type: application/json" \
  -d @triage-new-issues.json

It answers 201 with the task, as GET /v1/tasks/{id} reads it:

Field What it is
id The task's id. Every other call names the task by it.
slug The slug it was given: triage-new-issues here.
currentVersionId Its current version's id. Send it as baseVersionId when you change the task.
currentVersion The current version's definition.
skillMd The current skill.
versions Every version, newest first, with how many runs used each.
definitionIssues What the checks found that did not stop it saving.
webhook The URL that starts it from another system, and the header your webhook secret goes in. See Webhooks.
canEditDefinition false for a clone, whose definition belongs to the original's owner, and for the OS's own tasks, which are the same in every enterprise and change only with a release of the OS.
Status Code Why
400 invalid_name name is empty, or longer than 200 characters.
400 invalid_slug The slug is empty once cleaned, or longer than 80 characters.
400 invalid_task_definition The definition fails one of the checks.
400 llm_model_not_in_catalogue spend.model is not a model your connected provider offers. GET /v1/llm/models lists them.
400 assignee_member_removed The body names a member in assigneeMemberId. A task is given to a role: send assigneeRole.
400 serialize_on_invalid serializeOn is not null or a list of up to 8 input keys.
404 team_not_found teamId is not in your enterprise.
409 task_slug_taken Your enterprise already has a task with that slug.

A task that merges

A task that can merge a pull request must declare a gate on the merge, and hold os.set_gate_summary so its run can tell you what you are approving. Send all three in the same call:

{
  "name": "Merge approved pull requests",
  "assigneeRole": "release-engineer",
  "tools": ["github.pull_request_read", "github.merge_pull_request", "os.set_gate_summary"],
  "gates": [{ "tool": "github.merge_pull_request", "owner": "ceo", "approval": "specific" }]
}

Without the gate, the call is refused with gate_required_for_tool. That holds however tools reaches the merge: by its name, or through github or github.*, which hold every GitHub tool. The gate must name github.merge_pull_request itself: a gate on github or github.* does not count. If a task without that gate reaches a run anyway, the run ends at the merge call with gate_required_for_tool, and nothing is merged.

The merge is on this rule because your GitHub connection marks it must-gate. Every connection carries such a list (mustGate on GET /v1/tools/bindings), a GitHub connection starts with the merge on it, and only the CEO can change it. Create, save and a new version are all checked against the lists of every connection your enterprise has. See Gates.

The checks

Creating a task and saving a version both check the definition against your enterprise's other tasks. An error refuses the call with a 400:

{
  "error": "invalid_task_definition",
  "message": "A deterministic task has no model to call its tools, so every run fails. Use kind \"llm\".",
  "issues": [
    {
      "severity": "error",
      "code": "deterministic_with_tools",
      "message": "A deterministic task has no model to call its tools, so every run fails. Use kind \"llm\"."
    }
  ]
}

message joins every error's sentence, and issues lists them one by one. A warning does not refuse the call: it is listed in the task's definitionIssues (read the task after a save to see them) and, for an active task, on GET /v1/blockers under taskProblems, until you fix it.

Code Severity When
gate_required_for_tool error tools can reach github.merge_pull_request, by its name or through github or github.*, and gates declares no gate naming it; or tools lists os.mail_send and gates declares no gate on it; or tools lists os.write_plan and gates declares no gate on os.ask_ceo.
gate_summary_tool_required error tools can reach github.merge_pull_request, by its name or through github or github.*, but does not list os.set_gate_summary.
deterministic_with_tools error kind is deterministic and tools is not empty.
successor_when_unknown error A successor's when is not has_issue, has_pr or no_pr.
successor_cap_invalid error A successor's maxRunsPerIssue is not a whole number of at least 1.
successor_delay_invalid error A successor's delayMinutes is not more than 0 and at most 1440.
unconditional_loop error Successors on terminal lead back to this task, so it would restart for ever.
successor_unknown_slug warning A successor names a task your enterprise does not have, so the work stops there.
successor_on_unknown warning A successor waits for an outcome the OS does not know. Usually a typo.

Change a task

POST /v1/tasks/{id}/versions publishes the task's next version. Send only what changes: every field you leave out keeps its value from the current version, skillMd included. To clear inputs, workspace or successors, send null.

Send baseVersionId too: the currentVersionId you read before you made your change.

curl -X POST https://api.zerohuman.com/v1/tasks/$TASK_ID/versions \
  -H "Authorization: Bearer zhos_…" \
  -H "content-type: application/json" \
  -d '{
    "baseVersionId": "0b7c4a52-3d1e-4f6a-8c2b-9e5d7a1f3c60",
    "guardrails": ["Never close an issue.", "Never promise a release date.", "Reply in English."]
  }'

It answers 201 with the new version: its id, its version number, and its definition. That id is your next baseVersionId.

If someone published a version after the one you read (a person, a script, or a self-improvement change you approved), your save is refused, so it cannot put the older definition back over theirs:

{ "message": "task_version_conflict", "error": "Conflict", "statusCode": 409 }

Read the task again, make your change to what is there now, and save against the new currentVersionId. A save without baseVersionId is not checked this way: it publishes over whatever is current.

The same call takes the task's own fields: name, slug, teamId, assigneeRole, serializeOn, status and kind. These are not part of a version. They change the task at once, and the version history does not record them, though the call still publishes a new version. A new slug changes the task's webhook URL, and successors that name the old slug no longer find it. The save does not check that a new slug is free, so choose one no other task has.

Status Code Why
400 invalid_task_definition The new version fails one of the checks.
400 assignee_member_removed The body names a member in assigneeMemberId. A task is given to a role: send assigneeRole.
400 serialize_on_invalid serializeOn is not null or a list of up to 8 input keys.
400 skillMd_required The skill would be empty.
400 llm_model_not_in_catalogue spend.model is not a model your connected provider offers.
403 task_definition_read_only The task is a clone: its definition belongs to the original's owner. Copy it to make your own.
404 task_not_found No task with that id in your enterprise.
409 task_version_conflict Someone published a newer version than your baseVersionId.
409 os_task_immutable It is one of the OS's own tasks: the same in every enterprise, changed only with a release of the OS, never through the API.

Saving a version declines any self-improvement recommendation still waiting on the task, since it was written against the version before. See Self-improvement.

Versions

GET /v1/tasks/{id}/versions lists every version, newest first. GET /v1/tasks/{id}/versions/{versionId} reads one as it was, with its skillMd, how many runs used it, and whether it is current. A version never changes once saved. To go back to an earlier one, read it and publish its fields as a new version.

Start a run

POST /v1/tasks/{id}/runs starts a run on the task's current version. The body is the run's input, a JSON object:

curl -X POST https://api.zerohuman.com/v1/tasks/$TASK_ID/runs \
  -H "Authorization: Bearer zhos_…" \
  -H "content-type: application/json" \
  -d '{"repo": "acme/web", "issue": 128}'

It answers 201 with the new run, queued, and its id.

The body becomes the run's input as sent. It is not checked against the task's inputs, and their defaults are not filled in, so send every value the run needs.

{id} is the task's id. To find it from the slug, GET /v1/tasks?slug=triage-new-issues lists the tasks whose slug contains it. A webhook starts a task by its slug instead, with your webhook secret rather than a token.

Status Code Why
404 task_not_found No task with that id in your enterprise.
404 task_not_runnable It cannot take new work: it is deprecated, or nobody can run it (it has no assigneeRole, or nobody can take that role on its team or enterprise-wide). A deprecated task answers this even while a run of it is under way.
409 overlap The task already has a run under way. A run waiting at a gate does not count.
409 member_busy Every member holding its role on its team is busy with other work.

A refused start begins nothing, and appears on Runs as skipped_overlap. Starting a run is not safe to repeat blindly: see Safe retries.

Read the run

GET /v1/runs/{id}, with runs:read, reads the run with its events and gates:

{
  "run": {
    "id": "5c0e9f7a-1b2d-4e3f-a4b5-c6d7e8f90a1b",
    "status": "succeeded",
    "input": { "repo": "acme/web", "issue": 128 },
    "costUsdCents": 4,
    "startedAt": "2026-09-28T09:00:03.000Z",
    "finishedAt": "2026-09-28T09:01:41.000Z"
  },
  "events": [{ "id": "…", "type": "run.succeeded", "payload": { "result": "…" }, "createdAt": "2026-09-28T09:01:41.000Z" }],
  "gates": []
}

The answer also carries the task and version it ran, what came before it and what it handed to, its execution, and its cost.

The API does not call you when a run ends, so read it until it has:

  • succeeded, failed or cancelled: it has ended. A run.succeeded event carries its result, and a run.failed event its reason.
  • waiting_on_gate: it waits for your decision, and gates holds what it waits on. Decide it on Gates, or with POST /v1/gates/{id}/decision and a token scoped gates:write.
  • blocked: it is held for a reason outside the task, such as a refused credential or a provider outage, and blockedReason says which. Most blocked runs resume by themselves.
  • paused_spend: a spend cap was reached. It is queued again when the cap is raised or spend limits are turned off. See Spend and models.

Every status is on Runs. On later reads, send afterEventId with the id of the newest event you hold, and only the events recorded since come back. A few from the same second may come again: skip the ids you already have.

GET /v1/runs/{id}/log answers with the run's full log in body once it is stored, and 404 (log_not_found) until then. GET /v1/runs?taskId=… lists a task's runs, newest first.