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,failedorcancelled: it has ended. Arun.succeededevent carries itsresult, and arun.failedevent itsreason.waiting_on_gate: it waits for your decision, andgatesholds what it waits on. Decide it on Gates, or withPOST /v1/gates/{id}/decisionand a token scopedgates:write.blocked: it is held for a reason outside the task, such as a refused credential or a provider outage, andblockedReasonsays 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.