API Reference

Every endpoint, field and error key of /api/v1. The tables are generated from the code that serves them, so they cannot drift from it.

Last updated July 19, 2026

Overview

The Zumino API reads and writes a workspace's work over HTTP: projects, items, requests, tasks, epics, comments, votes and tags. It is a plain JSON REST API — no SDK to install, no GraphQL, no websockets. Every request is a single authenticated call and every response is JSON.

It exists for the things a browser tab is bad at: filing a bug from a CI job, triaging a queue from a script, or letting a coding agent read and update a roadmap while it works. The Claude Code skill at the bottom of this page is one such client, and it uses nothing that is not documented here.

Base URL

https://<host>/api/v1

Every path in this document is relative to that base. Zumino can be self-hosted, so <host> is the origin of your own instance.

Machine-readable spec

The same contract is published as an OpenAPI 3.1.0 document at /api/v1/openapi.json. It is the only endpoint that needs no token — a spec you must authenticate to read is useless for discovery — so an agent can fetch it, learn every path, parameter and response shape, and start calling.

It is generated from the same Zod schemas the server validates requests with and builds responses from, and so are the field tables on this page. Neither is transcribed by hand, which is why neither can quietly fall behind the running code.

The stability boundary

/api/v1 is a contract. The response shapes are built field by field in one module and pinned by tests, so a column added to the database tomorrow cannot appear in a response by accident. Within a version, a documented field will not be removed, renamed, or change type; new fields may be added, so parse leniently and ignore what you do not recognise.

The version lives in the path, not in a header. A breaking change means a new path (/api/v2), not a new value in an Acceptheader — so nothing you deploy today starts answering differently tomorrow. The spec reports the contract's own version, 1.1.0, which moves independently of the Zumino release you are running.

Other paths under /api are internal machinery for the web app. They are undocumented, unversioned, and change without notice. If it is not on this page, it is not part of the API — do not call it.

Conventions

  • Request bodies are JSON and require Content-Type: application/json. A body that is not valid JSON is a 400, never a 500.
  • All timestamps are ISO-8601 strings in UTC, e.g. "2026-06-28T11:02:44.310Z". There are no epoch numbers and no bare dates.
  • A successful read is 200; a successful create is 201; tag attach and detach are 204 with an empty body. Everything else is an error envelope.
  • Nothing about workspace internals is published: no organization ids, no author ids, no membership roles, no in-app project slugs.

Authentication

Every endpoint requires an API token, sent as a bearer token — a personal one (zumino_live_…) or an agent's (zumino_agent_…, below):

Authorization: Bearer zumino_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A missing, malformed, unknown, expired, or revoked token is 401 with the key authRequired. There is no anonymous access, even to a public project. The one exception is the OpenAPI document, which describes the API rather than exposing any of it.

Minting a token

Tokens are created in the web app under Account → API tokens (/app/account). A token:

  • is personal. It acts as you: it can reach exactly the projects you can reach, and everything it writes is attributed to your name. It is not a workspace or service credential.
  • is shown once. The plaintext appears at creation and is never recoverable — only a hash is stored. Lost it, copy it wrong, or leak it, and the fix is to revoke and mint a new one.
  • may expire. You choose an expiry at creation, or none at all. Expired and revoked tokens both answer 401.
  • may be read-only. See below.
  • may be limited to one workspace. Chosen at creation. A limited token answers every path naming another workspace — a public board included — with 404 and the key notMember, exactly as for a workspace that does not exist, and GET /workspaces, GET /projects and GET /queue list only its one.

Agent tokens

An agent is a member of a workspace that is software rather than a person: a name, a role, the projects it may work in, and its own tokens, made under Settings → Agents by an admin or a member with the Agents add-on. An agent token looks like zumino_agent_… and is sent the same way. It acts as the agent— everything it writes is recorded under the agent's own name — with exactly the agent's role, and only in the agent's one workspace. GET /me says which it is: user.kind is person or agent. An agent cannot be made an item's assignee or reviewer (400, agentNotAssignable), and does not set its own name or picture (403, agentIdentityManaged).

Treat it like a password: keep it in an environment variable or a secret manager, never in a repository, a shell history file, or a CI log.

Read-only tokens

A token marked read-only is refused on every non-GET request — before the request body is even read — with 403 and the key tokenReadOnly. This is checked at the edge and applies regardless of the owner's permissions in the workspace, so a read-only token cannot be talked into a write by any combination of path and body.

Ask the token itself rather than assuming: GET /me returns token.readOnly.

Permissions

Beyond the read-only flag, the API applies exactly the same rules as the web UI — there is no separate API permission model.

ActionWho
Read a project and its requestsAnyone for a public project; members only for a private one
Create a request, comment, voteAnyone signed in for a public project; for a private one, whoever may comment there — not a viewer
Edit a request's title, description, typeIts author while they may still comment on the project, or a workspace owner/admin
Set status or pinned; create tags; attach or detach tagsWorkspace owner/admin only

A project you may not see is 404, never 403: confirming that a private project exists at a given slug would itself be a leak.

Addressing

Every path names its workspace

A token acts as its owner across every workspace they belong to. If a project were addressed by a globally unique key, a wrong key would be a successful write in the wrong place — silent, and exactly the mistake an automated caller makes. So the workspace segment is a confirmation as much as an address: the handler checks that the project belongs to the workspace you named, and a mismatch is 404, never 403, so it cannot be used to probe what exists.

Start with GET /workspaces. Until you have a workspace slug there is no URL you can build. The two genuinely cross-workspace reads — GET /queue and GET /projects — stay unscoped and take an optional ?workspace= instead.

Projects: the workspace-scoped slug

The {project} segment is the project's slug, which is unique within its workspace — for example /workspaces/acme/projects/feedback/requests. publicSlug is still published, but it is no longer an API address: its one job now is the public page at /b/{publicSlug}.

Items: the kind, then the number

An item is addressed by its kind and its number — …/tasks/14, …/requests/42, …/epics/3. The {number}segment is the item's sequence number within its project and its kind, so a task and an epic may both be number 3. It is not an id; ids are published but no path takes one. A segment that is not a positive integer is a 400 with the key requestNotFound.

The kind's letter does not appear in the segment: …/epics/3, not …/epics/E3. The letter exists to disambiguate a code, and in a path the segment has already said which kind it is.

GET /workspaces/acme/projects/feedback/requests/42
                     ^ workspace  ^ project slug        ^ per-kind number

Codes: ONS-14, and where they resolve

A code is how an item is written down — ONS-14 a task, ONS-E3 an epic, ONS-R42a request — and it is what survives being pasted into a branch name, a commit message or an agent's context. Every item shape publishes one, and GET /workspaces/{workspace}/items/{code} resolves it. That endpoint is a lookup, not a second address: what it returns carries the canonical path to follow.

A code is scoped to a workspace, because the project key it is built from is unique per workspace and no wider. And a code does not always exist: project.key is null on every feedback project, whose requests are shown as a plain #42, so those items have code: null. That is the reason a code cannot be the canonical way to address an item.

Errors

Every failure — validation, permission, or fault — answers with the same envelope:

{
  "error": {
    "key": "projectNotFound",
    "message": "Project not found."
  }
}

key is a stable identifier and is the thing to branch on. message is developer-facing English, meant for a log line; it may be reworded at any time, so do not match on it or show it to end users. The keys are the same set the Zumino UI localizes, so a client can map them to its own translations.

A write refuses a field it does not take. Every POST, PUT, PATCH and DELETE answers 400 invalidInput when its body carries a key the endpoint does not list, at the top level or nested, and the message names it — Unknown field "epic" at the top level of the body. For "epic", send "epicNumber". — so a misspelt field is never a successful write of something else. An endpoint that takes no body refuses one that has fields; an empty body or {} is fine.

Status codes

StatusMeaning
200Success.
201Created — a request, comment, or tag now exists.
204Success, no body. Only tag attach and detach answer this.
400The request was malformed or failed validation — including a body field the endpoint does not take, which is refused as invalidInput naming the field rather than ignored. A rate limit is 429, never this.
401No usable token.
403Authenticated, but not allowed — including any write with a read-only token.
404No such project, request, tag, or workspace — or one you may not see.
409Conflict with something that already exists.
429A per-user write limit was hit. It carries a Retry-After header — sleep for it and repeat the same call. See Rate limits.
500A fault on our side. The key is always generic and the message carries no detail.

Error keys

StatusKeyWhat it means
401authRequiredToken missing, malformed, unknown, expired, or revoked. Mint a new one at /app/account.
403needAdminAn action that requires workspace owner/admin (status, pin, tag management) on something the caller can see.
403tokenReadOnlyAny write made with a read-only token. Use a token that can write.
403notRequestEditorEditing the content of someone else's request without moderator rights.
403notMemberActing on a public project in a workspace you are not a member of. On a private one the same call answers 404: what you may not know exists is never confirmed with a 403.
404notMemberFrom GET /projects?workspace=…: no workspace of yours has that slug or id.
404projectNotFoundNo project with that slug in that workspace — including a project that exists under a different workspace, which is the likeliest way to meet this key — or one you may not see. Note it is the workspace-scoped slug: publicSlug and the raw id are no longer accepted anywhere in a path.
404requestNotFoundNo request with that number on the project. Also returned as 400 when the segment is not a positive integer.
404tagNotFoundNo tag with that id.
409tagExistsThe project already has a tag with that name.
400tagWrongProjectThat tag id belongs to a different project than the request does.
400titleTooShortRequest title is under 3 characters after trimming.
400titleTooLongRequest title is over 140 characters.
400bodyTooLongA request description or comment body is over 5000 characters.
400commentEmptyComment body is empty after trimming.
400tagNameRequiredTag name is empty after trimming.
400tagNameTooLongTag name is over 30 characters.
400invalidInputThe body was not valid JSON, or failed a generic check.
429rateLimitedA per-user creation limit was hit. Wait and repeat the call. See Rate limits.
500genericServer fault. Report it; do not retry in a loop.

key is always a stable identifier, never a sentence. A value that fails a check with no dedicated key — an omitted required field, "type": "task", a non-boolean pinned — answers invalidInput, so the key is always something you can branch on and localize from. What it does not do is say which field was wrong: for that, check the body you sent against the request schema on this page.

The table above is the set you will meet most often, not the whole set — a lookup that names nothing answers taskNotFound, for instance. The full list is closed and versioned in the source (ERROR_KEYS), and new keys are added to it, so write a default branch rather than assuming these are all of them. The message beside the key is English text for a developer reading a log; it is not localized and is not a contract.

Vocabularies

These are closed sets. A value outside them is rejected on write; on the list filters, an unrecognised value is ignored rather than failing the whole read, so a client can pass a filter this version does not know.

Request type

ValueMeaning
featureA request for something new. The default on create.
bugSomething is broken.
improvementSomething exists but should be better.
ideaA suggestion, not yet a request.

Request status

The lifecycle is open → planned → in_progress → done | wont_do. It is a communication device, not a workflow engine: any status may be set from any other, and only owners/admins may set it.

ValueMeaning
openReceived, not yet triaged. The default on create.
plannedAccepted and intended.
in_progressBeing worked on now.
doneShipped. A closed status.
wont_doDeclined. A closed status.

done and wont_do are the closed statuses: entering either sets closedAt, and the public project collapses them into a “Closed” section below the live list.

Project visibility

ValueMeaning
publicReadable by anyone at /b/{publicSlug}.
privateReadable only by members of the owning workspace. Still has a slug, and still addressable through this API by anyone who is a member.

Tag color

Colours are names from the Zumino palette, not hex — the project renders them on brand. The default on create is wood.

ValueAppearance
woodWarm brown. The default.
akariAmber.
sealVermilion.
bambooPale warm sand.
sumiMuted ink grey.

Languages

Content locales are en, ja, and ru. They appear as a project's language, as a request or comment's originalLang, and as the keys of the *I18n maps.

Objects

These object shapes appear across the responses below. They are described once here; each endpoint then shows how they are wrapped. Every table in this section is rendered from the API's own schemas, in the order the server writes the fields.

Item

What every kind carries, and the only shape …/items returns — for a request, a task and an epic alike. There is deliberately no status: a status belongs to the kind that has one, and a kind may have seven stages, four, or none. state is derived from closedAt, so it is answerable for all of them. Follow pathfor the kind's own endpoint, which carries everything this omits.

FieldTypeNotes
idstringStable id.
kindstringWhich detail this item carries, and therefore which endpoint reads it in full: …/requests/{number}, …/tasks/{number}, …/epics/{number}. path below is that URL, already built. One of: request, task, epic.
codestring | nullHow the item is written down — ONS-14 a task, ONS-E3 an epic, ONS-R42 a request. *Null when the project has no key*, which is every feedback project: those address their requests as plain #42. Resolve one with GET /workspaces/{workspace}/items/{code}.
numbernumberThe item's key within its project and its kind — what …/tasks/{number} takes. A task and an epic may share a number.
titlestringThe author's original text.
descriptionstring | nullThe preview — what a list row shows. Capped; the long-form writing on a task is spec.plan, read from the task's own endpoint.
authorNamestring | nullThe public author label. Null for anonymous or nameless authors — never an email address or user id.
assigneePerson | nullThe accountable person — always a human, even when an agent does the work. Null when nobody is assigned, and on a kind that never assigns.
commentCountnumberTotal comments.
statestringDerived from closedAt alone, so it is answerable for a kind with no lifecycle. This is *not* a status: read the kind's own endpoint for the stage it is in. One of: open, closed.
closedAtstring | nullWhen the item was closed; null while it is still live.
createdAtstringISO-8601.
updatedAtstringISO-8601.
projectobjectThe project holding the item. Present on the project-scoped read too, so one row means the same thing wherever it was found.
pathstringThe API path of this item's own detail endpoint, relative to the server URL — build follow-up calls from it rather than assembling segments per kind.

Workspace

FieldTypeNotes
slugstringThe {workspace} path segment, and what ?workspace= filters on.
namestringDisplay name.
avatarUrlstring | nullThe workspace's avatar as a *512×512 square, or null when it has none — in which case draw its initials. Published here and not on the workspace reference* a project carries, for the same reason GET /me publishes the large edition and an assignee does not: this is the call that is about the workspace.
rolestringThe *caller's* role in this workspace. owner and admin may moderate — set a status, edit what someone else filed, write the internal note, read a request's history — and see every project. member works in the workspace's open projects. viewer reads what a member reads and changes nothing in the workspace — on a public board it files, votes and comments like any signed-in visitor. guest sees only the restricted projects it is listed on. More roles may be added; treat one you do not know as able to read at most. One of: owner, admin, member, viewer, guest.

Project

The last two fields appear only in GET /projects; GET /projects/{project} returns the project without them.

FieldTypeNotes
idstringStable id.
slugstringWhat {project} in a path takes. Unique within the workspace, which is why every path names one.
publicSlugstring | nullWhere the project is read by the public, at /b/{publicSlug}. Globally unique and permanent, and *not* an API address — paths take slug under a workspace. Null on a project with no public page.
namestringDisplay name.
descriptionstring | nullProject blurb, or null.
typestringWhat the project holds, and therefore which endpoints apply: feedback projects have …/requests, work projects have …/tasks and …/epics. …/items answers for both. Fixed when the project is created. One of: feedback, work.
keystring | nullThe prefix an item code is built from — "TOK" gives TOK-42. Null on a feedback project, whose requests are shown as plain #42 and therefore have no code at all.
visibilitystringHow the project is reached — see Vocabularies. One of: public, private.
accessstringWho in the workspace may see it. open: every member, at their role's level. restricted: only the people listed on it, each at view or edit, plus admins and owners. A different axis from visibility — a public project stays readable at /b/{publicSlug} whatever this says. The list itself is managed in the browser. One of: open, restricted.
languagestringThe locale the project is read and managed in, e.g. en.
autoTranslatebooleanWhen true, content written in another language is translated into language in the background after it is posted.
createdAtstringISO-8601.
updatedAtstringISO-8601.
requestCountnumberHow many requests the project holds — always 0 on a work project. List view only.
taskCountnumberHow many tasks the project holds — always 0 on a feedback project. List view only.
workspaceWorkspaceRefThe workspace that owns the project — its slug and name, and nothing else. List view only.

Request

The list and detail views are the same object, so a request looks identical whether you found it in a list or fetched it directly. The detail view adds the last two fields.

FieldTypeNotes
numbernumberThe request's key within its project — the #42 shown on the public board, and what …/requests/{number} takes. Not an id, and not unique across projects.
titlestringThe author's original text.
descriptionstring | nullThe author's original text, or null.
originalLangstringThe locale the author wrote in, e.g. "ja".
titleI18nobject | nullTranslations of the title keyed by locale, e.g. {"en": "…"}. Null when none exists — the project does not auto-translate, the original is already in the project's language, or the translation has not run yet.
descriptionI18nobject | nullSame shape, for the description.
typestringSee Vocabularies. One of: feature, bug, improvement, idea.
statusstringSee Vocabularies. One of: open, planned, in_progress, done, wont_do.
voteCountnumberTotal votes.
commentCountnumberTotal comments.
pinnedbooleanPinned requests sort first, ahead of any sort order.
authorNamestring | nullThe public author label. Null for anonymous or nameless authors — never an email address or user id.
attachmentsAttachment[] | nullFiles on the request, or null when it has none.
createdAtstringISO-8601.
tagsTag[]The tags on this request. Empty array when none.
hasVotedbooleanWhether the calling token's owner has voted on this request.
updatedAtstringISO-8601. Detail view only.
closedAtstring | nullWhen the request reached done or wont_do; null while it is still live. Detail view only.

Tag

FieldTypeNotes
idstringUsed in ?tag= and in the tag attach/detach paths. Not the name.
namestringUnique within the project.
colorstringA palette name — see Vocabularies. Not a hex value.

Comment

FieldTypeNotes
idstringStable — useful for de-duplicating across polls, and what PUT …/requests/{number}/answer names.
bodystringThe author's original text.
isOfficialbooleanWhether this comment is the team's official answer. *At most one per request*, so a client can render it above the thread without deciding between two. Set it with PUT …/requests/{number}/answer.
originalLangstringThe locale it was written in.
bodyI18nobject | nullTranslations keyed by locale, or null.
authorNamestring | nullPublic author label, or null.
createdAtstringISO-8601.

Attachment

FieldTypeNotes
idstringStable id. Pass it to DELETE …/attachments/{id} to remove the file.
urlstringPath this file is served from, relative to the API host. It is *not* public: the request is authorized like any other, so send the same token, and expect a 302 to a short-lived storage URL that you should follow rather than store.
namestringOriginal filename.
typestringMIME type, e.g. image/png. Decided from the file's own bytes at upload time, not from what the uploader declared.
sizenumberBytes.

Endpoints

The examples assume two environment variables, so they can be pasted as they are:

export ZUMINO_URL="https://zumino.example.com"   # origin, no trailing slash
export ZUMINO_TOKEN="zumino_live_…"

Ids and timestamps in the responses below are illustrative; the field names, nesting, and types are exact.

GET/me

Who the token acts as, and what it may do. The cheap call to make at startup to check a configuration.

curl -sS "$ZUMINO_URL/api/v1/me" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "user": {
    "id": "8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34",
    "name": "Mei Tanaka",
    "email": "[email protected]",
    "avatarUrl": "https://cdn.zumino.cc/avatars/u/8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34/V1StGXR8Z5jdHi6BmyT.webp/512.webp",
    "kind": "person"
  },
  "token": {
    "readOnly": false
  }
}

token.readOnly is the only thing published about the token — never its id, name, or prefix. user is null in the pathological case of a live token whose owner account no longer exists.

GET/workspaces

The call to make first. Every other path carries a {workspace} segment, so until you have read this there is no URL to build. role comes with each one because it predicts whether a moderator-only write will be refused.

curl -sS "$ZUMINO_URL/api/v1/workspaces" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "workspaces": [
    {
      "slug": "acme",
      "name": "Acme",
      "avatarUrl": "https://cdn.zumino.cc/avatars/w/org_7Fk2Qd/3Zp7QkLm2XbNdVtRyCeWa.webp/512.webp",
      "role": "admin"
    },
    {
      "slug": "onsenoni",
      "name": "Onsen Oni",
      "avatarUrl": null,
      "role": "member"
    }
  ]
}

There is no endpoint that creates a workspace or changes who is in one. Membership is a security boundary: a token that could add members would turn a leak into access that revoking the token does not undo.

GET/workspaces/{workspace}/items

The one endpoint that knows more than one kind of item exists. It answers the spine shape — what a request, a task and an epic genuinely share — so a cross-kind list, a sort or a link picker can be built without a query per kind, and a kind added later shows up here without this endpoint changing. Every filter is a spine field for the same reason; a filter belonging to one kind, like priority or votes, lives on that kind's own list.

QueryTypeNotes
kindstring[]Repeatable (?kind=task&kind=epic) or comma-separated. Omit for every kind. A value that is not a kind matches nothing rather than widening to all — asking for a kind that does not exist should not answer with the ones that do.
assigneestringA user id. assignee is on the spine precisely so that “what is assigned to me across every project and every kind” is one call.
statestringopen or closed, from closedAt. Not a status — the kind's own endpoint has that. One of: open, closed.
qstringSubstring of the title or description, case-insensitive.
sortstringupdated (default), new, or code. Sorting by code orders by project key, then kind, then number, so ONS-9 precedes ONS-10 rather than following it as text would. One of: updated, new, code.
offsetnumberRows to skip. Default 0.
limitnumberPage size. Default 50, clamped to 200.
curl -sS "$ZUMINO_URL/api/v1/workspaces/onsenoni/items?state=open&sort=updated" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "items": [
    {
      "id": "c1f8a20d-6b34-4e97-8a15-3d72e0b9c48f",
      "kind": "task",
      "code": "ONS-14",
      "number": 14,
      "title": "Paginate the export endpoint",
      "description": "The CSV export loads every row before writing a byte.",
      "authorName": "Mei Tanaka",
      "assignee": {
        "id": "8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34",
        "name": "Mei Tanaka",
        "avatarUrl": "https://cdn.zumino.cc/avatars/u/8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34/V1StGXR8Z5jdHi6BmyT.webp/96.webp"
      },
      "commentCount": 3,
      "state": "open",
      "closedAt": null,
      "createdAt": "2026-07-02T10:04:55.120Z",
      "updatedAt": "2026-08-11T16:20:31.409Z",
      "project": {
        "slug": "app",
        "name": "Onsen Oni",
        "key": "ONS"
      },
      "path": "/workspaces/onsenoni/projects/app/tasks/14"
    },
    {
      "id": "9b0e4d75-1c62-4f38-a7d9-5e13c8f0a24b",
      "kind": "epic",
      "code": "ONS-E3",
      "number": 3,
      "title": "Exports that survive a large account",
      "description": null,
      "authorName": "Ito Kenji",
      "assignee": null,
      "commentCount": 0,
      "state": "open",
      "closedAt": null,
      "createdAt": "2026-06-30T08:00:00.000Z",
      "updatedAt": "2026-08-09T12:45:02.880Z",
      "project": {
        "slug": "app",
        "name": "Onsen Oni",
        "key": "ONS"
      },
      "path": "/workspaces/onsenoni/projects/app/epics/3"
    },
    {
      "id": "2a7c6e13-84b0-4d59-9f21-6c05b3e7d19a",
      "kind": "request",
      "code": null,
      "number": 42,
      "title": "CSV export drops the last row when paginated",
      "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
      "authorName": "Mei Tanaka",
      "assignee": null,
      "commentCount": 2,
      "state": "closed",
      "closedAt": "2026-08-01T09:30:12.664Z",
      "createdAt": "2026-06-28T11:02:44.310Z",
      "updatedAt": "2026-08-01T09:30:12.664Z",
      "project": {
        "slug": "feedback",
        "name": "Acme Feedback",
        "key": null
      },
      "path": "/workspaces/acme/projects/feedback/requests/42"
    }
  ],
  "total": 3
}

Note what is the same across all three rows: one key set, no status anywhere, and state read off closedAt. The request has code: null because its project has no key — ordinary, not a gap.

GET/workspaces/{workspace}/projects/{project}/items

The same read, narrowed to one project. The one endpoint that does not care what a project holds: …/requests is a 404 on a work project and …/tasks is a 404 on a feedback one, and this answers for both.

GET/workspaces/{workspace}/items/{code}

Resolve a pasted code. A lookup, not a second address for an item: what comes back carries the canonical pathto follow for the kind's own detail.

curl -sS "$ZUMINO_URL/api/v1/workspaces/onsenoni/items/ONS-14" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "item": {
    "id": "c1f8a20d-6b34-4e97-8a15-3d72e0b9c48f",
    "kind": "task",
    "code": "ONS-14",
    "number": 14,
    "title": "Paginate the export endpoint",
    "description": "The CSV export loads every row before writing a byte.",
    "authorName": "Mei Tanaka",
    "assignee": {
      "id": "8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34",
      "name": "Mei Tanaka",
      "avatarUrl": "https://cdn.zumino.cc/avatars/u/8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34/V1StGXR8Z5jdHi6BmyT.webp/96.webp"
    },
    "commentCount": 3,
    "state": "open",
    "closedAt": null,
    "createdAt": "2026-07-02T10:04:55.120Z",
    "updatedAt": "2026-08-11T16:20:31.409Z",
    "project": {
      "slug": "app",
      "name": "Onsen Oni",
      "key": "ONS"
    },
    "path": "/workspaces/onsenoni/projects/app/tasks/14"
  }
}

GET/queue

What to work on next, across every project the caller can reach, highest priority first — the read an agent starts a session with. By default it answers with available work: committed, shaped and unblocked, all three computed. An empty answer means the specs are below the bar or their blockers are open, not that there is nothing to do.

QueryTypeNotes
workspacestringNarrow to one workspace, by its slug or id. One the caller is not a member of matches nothing: the answer is empty, not a 404.
projectstringNarrow by project: its key (ONS), its slug or its id. A key and a slug are unique only within a workspace, so across several they match every project carrying them — add ?workspace= to mean exactly one. Matching nothing gives an empty answer.
needsInputbooleantrue switches the question from available work to open tasks flagged as waiting on the caller — assigned to them, or unassigned and filed by them — blocked or not. Any other value is the default. Defaults to false.
limitnumberClamped to 1–200, however large a value is sent. Defaults to 50.
curl -sS "$ZUMINO_URL/api/v1/queue?workspace=onsenoni&project=ONS&limit=5" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"

GET/workspaces/{workspace}/projects/{project}/tasks

One project's tasks, with every filter the board itself has, plus the number matching them before pagination. sort=manual is the stored order — what the board and the backlog draw — so ?status=backlog&sort=manual reads a backlog in the order somebody put it in.

QueryTypeNotes
statusstring[]Repeatable (?status=todo&status=in_progress) or comma-separated (?status=todo,in_progress). Omit for every status. A value that is not a status is ignored. Each one of: backlog, todo, shaping, in_progress, in_review, done, wont_do.
typestringOne task type. One of: feature, bug, improvement, refactor, investigation, chore.
prioritystringOne priority. One of: critical, high, normal, low.
assigneestringA user id.
needsInputbooleantrue keeps only tasks flagged as waiting on a person. Defaults to false.
openbooleantrue hides closed tasks (done, wont_do). Defaults to false.
tagstringA tag id (not a name), from GET …/projects/{project}/tags. One tag at a time.
epicstringAn epic number, or none for work belonging to no epic. An empty value is the same as leaving it out; anything else is a 400 rather than an unfiltered list.
qstringCase-insensitive substring match against the title or description.
sortstringpriority (default), new, updated, or manual — the stored order the board draws. Any other value is treated as priority. One of: priority, new, updated, manual. Defaults to priority.
offsetnumberNegative values are treated as 0. Defaults to 0.
limitnumberClamped to 1–200, however large a value is sent. Defaults to 50.

POST/workspaces/{workspace}/projects/{project}/tasks

A title is the only requirement. Capture has to cost one sentence or it does not happen; fill the description in with PATCH, and the plan and the acceptance criteria with PUT …/spec/{section}, one section at a time — which is what lets an agent redraft a plan without touching the criteria it will be judged against.

POST/workspaces/{workspace}/projects/{project}/tasks/batch

One transaction: every task lands or none does. That is the only thing this buys over calling the endpoint above N times, and it is worth having because the alternative has no undo — a run interrupted halfway leaves an epic holding some of its tasks, and a half-created epic reads exactly like a real one.

Each item takes everything a single create takes, plus spec.plan and spec.acceptance inline, so a shaped task is one call rather than three. Later writes to either section still go one at a time. Validation runs over the whole array before anything is written, so one bad item creates none of the others and the error names its index.

Body fieldTypeNotes
tasksTaskBatchItem[]Required. The tasks to file, in the order they should be numbered. At least one, at most 50.

Each task in that array:

FieldTypeNotes
titlestringThe only required field — capture has to be cheap.
descriptionstring | nullWhat this is, in a couple of paragraphs. Markdown. Half the bar for GET /queue — a task nobody has described is not offered to anyone. The document to work from goes in spec.plan.
typestring One of: feature, bug, improvement, refactor, investigation, chore.
prioritystring One of: critical, high, normal, low.
statusstringDefaults to backlog. Must exist on the board's type. One of: backlog, todo, shaping, in_progress, in_review, done, wont_do.
sizestring | null
claritystring | null
assigneeIdstring | null
epicNumbernumber | nullThe epic's number on this board, or null to detach. Not an id.
specobject | nullBoth sections, written at filing. Later writes go one section at a time through PUT …/spec/{section}.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/onsenoni/projects/app/tasks/batch" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tasks": [
      {
        "title": "Back off on a 500",
        "description": "The webhook gives up after the first one.",
        "type": "bug",
        "epicNumber": 6,
        "spec": {
          "plan": "Exponential, capped at three attempts.",
          "acceptance": "A 500 is retried twice, then recorded as failed."
        }
      },
      { "title": "Name the retry budget", "status": "todo" }
    ]
  }'

It buys round trips, not allowance. Every task is charged to the same per-user budget a single create spends from, and a batch larger than what is left is refused whole with 429 and Retry-After — nothing is charged, so sleep for it and send the identical call.

A batch is charged for what it creates. It is the one endpoint that validates before it bills, so a 4xx costs nothing — the rule below, that the budget is spent before the body is validated, would otherwise make one mistyped epic number cost fifty of a hundred and the corrected call get refused for having been right.

On a 5xx, read before re-sending. The response is built by re-reading each task after the transaction commits, so a failure there describes tasks that exist. The write is still all-or-nothing; what was lost is the answer.

Batch when the whole set is known up front; incremental authoring stays one call at a time, because a batch has to be assembled before it can be sent.

GET/projects

Every project the caller can reach, across all their workspaces, oldest workspace first and project creation order within each.

QueryTypeNotes
workspacestringNarrow to one workspace, by its slug or id. A workspace the caller is not a member of is a 404 (notMember), not an empty list — silently returning nothing would hide a typo.
curl -sS "$ZUMINO_URL/api/v1/projects?workspace=acme" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "projects": [
    {
      "id": "3b9f7e21-8c04-4a15-bd62-7f0c9a4e8d13",
      "slug": "feedback",
      "publicSlug": "acme-feedback",
      "name": "Acme Feedback",
      "description": "Feature requests and bug reports for Acme.",
      "type": "feedback",
      "key": null,
      "visibility": "public",
      "access": "open",
      "language": "en",
      "autoTranslate": true,
      "createdAt": "2026-03-02T09:14:22.517Z",
      "updatedAt": "2026-07-11T04:41:08.902Z",
      "requestCount": 128,
      "taskCount": 0,
      "workspace": {
        "slug": "acme",
        "name": "Acme"
      }
    }
  ]
}

No endpoint creates, updates or deletes a project. A project takes a permanent, workspace-unique key, and an agent inventing one is a name you live with — there is no automation value on the other side of that trade. GET /workspaces/{workspace}/projects is the workspace-scoped form of this read.

GET/workspaces/{workspace}/projects/{project}

One project and its counters.

curl -sS "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "project": {
    "id": "3b9f7e21-8c04-4a15-bd62-7f0c9a4e8d13",
    "slug": "feedback",
    "publicSlug": "acme-feedback",
    "name": "Acme Feedback",
    "description": "Feature requests and bug reports for Acme.",
    "type": "feedback",
    "key": null,
    "visibility": "public",
    "access": "open",
    "language": "en",
    "autoTranslate": true,
    "createdAt": "2026-03-02T09:14:22.517Z",
    "updatedAt": "2026-07-11T04:41:08.902Z"
  },
  "stats": {
    "total": 128,
    "votes": 341,
    "comments": 96,
    "byStatus": {
      "open": 74,
      "planned": 18,
      "in_progress": 9,
      "done": 23,
      "wont_do": 4
    }
  }
}

project here has no requestCount or workspace — those belong to the list view. stats.byStatus always carries all five keys, including zeroes.

GET/workspaces/{workspace}/projects/{project}/requests

A page of the project's requests, plus the total number matching the filters before pagination.

QueryTypeNotes
statusstring[]Repeatable (?status=open&status=planned) or comma-separated (?status=open,planned). Omit for all statuses. Each one of: open, planned, in_progress, done, wont_do.
typestringOne request type. One of: feature, bug, improvement, idea.
tagstringA tag id (not a name), from GET …/projects/{project}/tags. One tag at a time.
qstringCase-insensitive substring match against the title or description.
sortstringtop is most votes first, newest breaking ties; new is newest first. Any other value is treated as top. Pinned requests always sort first either way. One of: top, new. Defaults to top.
limitnumberClamped to 1–200, however large a value is sent. Defaults to 50.
offsetnumberNegative values are treated as 0. Defaults to 0.
curl -sS "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests?status=open,planned&sort=top&limit=2" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "requests": [
    {
      "number": 42,
      "title": "CSV export drops the last row when paginated",
      "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
      "originalLang": "en",
      "titleI18n": null,
      "descriptionI18n": null,
      "type": "bug",
      "status": "open",
      "voteCount": 17,
      "commentCount": 1,
      "pinned": false,
      "authorName": "Mei Tanaka",
      "attachments": null,
      "createdAt": "2026-06-28T11:02:44.310Z",
      "tags": [
        {
          "id": "c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246",
          "name": "regression",
          "color": "seal"
        }
      ],
      "hasVoted": true
    },
    {
      "number": 37,
      "title": "ダークモードに対応してほしい",
      "description": null,
      "originalLang": "ja",
      "titleI18n": {
        "en": "Please add dark mode support"
      },
      "descriptionI18n": null,
      "type": "feature",
      "status": "planned",
      "voteCount": 12,
      "commentCount": 0,
      "pinned": false,
      "authorName": null,
      "attachments": [
        {
          "id": "aT4kQ2m9xLpR7vNbC1sYd",
          "url": "/api/files/aT4kQ2m9xLpR7vNbC1sYd",
          "name": "screenshot.png",
          "type": "image/png",
          "size": 184320
        }
      ],
      "createdAt": "2026-06-19T02:35:11.004Z",
      "tags": [],
      "hasVoted": false
    }
  ],
  "total": 92
}

Request #37 shows the multilingual case: titleis always the author's original, and titleI18n carries the project-language translation when the project auto-translates. Render titleI18n[locale] if present, otherwise title.

GET/workspaces/{workspace}/projects/{project}/requests/{number}

One request with its comments, oldest first. Tags and the caller's vote state ride inside request, exactly as they do in the list — there is no separate top-level tags key. This is the only way to read comments; there is no comments collection endpoint.

curl -sS "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/42" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "request": {
    "number": 42,
    "title": "CSV export drops the last row when paginated",
    "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
    "originalLang": "en",
    "titleI18n": null,
    "descriptionI18n": null,
    "type": "bug",
    "status": "in_progress",
    "voteCount": 17,
    "commentCount": 2,
    "pinned": false,
    "authorName": "Mei Tanaka",
    "attachments": null,
    "createdAt": "2026-06-28T11:02:44.310Z",
    "tags": [
      {
        "id": "c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246",
        "name": "regression",
        "color": "seal"
      }
    ],
    "hasVoted": true,
    "updatedAt": "2026-07-14T08:22:19.771Z",
    "closedAt": null
  },
  "comments": [
    {
      "id": "5d80b3fe-7a41-4c62-9e08-1b4f6d2a3c57",
      "body": "Confirmed on 2.4.1 — the off-by-one is in the paging helper.",
      "isOfficial": false,
      "originalLang": "en",
      "bodyI18n": null,
      "authorName": "Ito Kenji",
      "createdAt": "2026-06-29T07:12:03.884Z"
    },
    {
      "id": "ae13c9d0-2f76-4a38-bb54-908e7c1d5f62",
      "body": "Picked this up — shipping behind a flag this week.",
      "isOfficial": true,
      "originalLang": "en",
      "bodyI18n": null,
      "authorName": "Mei Tanaka",
      "createdAt": "2026-07-14T08:22:19.771Z"
    }
  ]
}

POST/workspaces/{workspace}/projects/{project}/requests

File a request as the token's owner. On a public project, open to anyone signed in; on a private one, to whoever may comment there — a viewer reads it and is refused. Under a per-user rate limit. Responds 201.

Body fieldTypeNotes
titlestringRequired. Trimmed; 3–140 characters.
descriptionstringOptional. Trimmed; up to 5000 characters.
typestringOptional. What kind of thing the request is — see Vocabularies. One of: feature, bug, improvement, idea. Defaults to feature.
sourceLangstringOptional. The language you are writing in. Omit and it is detected from the text. One of: en, ja, ru.
translatebooleanOptional. Set false to skip auto-translation for this request on a project that has it enabled.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "CSV export drops the last row when paginated",
    "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
    "type": "bug"
  }'
{
  "request": {
    "number": 129,
    "title": "CSV export drops the last row when paginated",
    "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
    "originalLang": "en",
    "titleI18n": null,
    "descriptionI18n": null,
    "type": "bug",
    "status": "open",
    "voteCount": 0,
    "commentCount": 0,
    "pinned": false,
    "authorName": "Mei Tanaka",
    "attachments": null,
    "createdAt": "2026-07-19T13:41:57.208Z",
    "tags": [],
    "hasVoted": false,
    "updatedAt": "2026-07-19T13:41:57.208Z",
    "closedAt": null
  }
}

The response is the full detail shape, so request.number is available immediately — that is the handle for every follow-up call. Translation, when the project enables it, runs after the response is sent, so titleI18n is null here and filled in moments later.

PATCH/workspaces/{workspace}/projects/{project}/requests/{number}

Update a request. Send only the fields you are changing; the response is the request re-read in full, identical in shape to GET …/requests/{number}'s request.

Body fieldTypeNotes
titlestringOptional. Trimmed; 3–140 characters. Author or owner/admin.
descriptionstring | nullOptional. Trimmed; up to 5000 characters. null clears it. Author or owner/admin.
typestringOptional. Author or owner/admin. One of: feature, bug, improvement, idea.
statusstringOptional. Owner/admin only. Moving to done or wont_do sets closedAt, which is what flips the item's state on the generic …/items read. One of: open, planned, in_progress, done, wont_do.
pinnedbooleanOptional. Owner/admin only. Absolute, not a toggle, so re-sending the same value is a no-op.

A patch is not transactional across permission levels: content, status, and pin are applied in that order and each is checked on its own, so a mixed patch by a non-moderator can apply the content change and then fail on status. Send status and pin changes on their own if that matters to you.

curl -sS -X PATCH "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/42" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "in_progress"}'
{
  "request": {
    "number": 42,
    "title": "CSV export drops the last row when paginated",
    "description": "Repro: /api/export?limit=50 with 51 matches returns 50 rows.",
    "originalLang": "en",
    "titleI18n": null,
    "descriptionI18n": null,
    "type": "bug",
    "status": "in_progress",
    "voteCount": 17,
    "commentCount": 2,
    "pinned": false,
    "authorName": "Mei Tanaka",
    "attachments": null,
    "createdAt": "2026-06-28T11:02:44.310Z",
    "tags": [
      {
        "id": "c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246",
        "name": "regression",
        "color": "seal"
      }
    ],
    "hasVoted": true,
    "updatedAt": "2026-07-19T13:44:02.615Z",
    "closedAt": null
  }
}

Deleting a request is not part of this surface. Close it with status: "wont_do" instead, which keeps the conversation and the request number intact.

POST/workspaces/{workspace}/projects/{project}/requests/{number}/comments

Comment as the token's owner, under a per-user rate limit. Responds 201. Comments are read back through the request, not from here.

Body fieldTypeNotes
bodystringRequired. Trimmed; 1–5000 characters.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/42/comments" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "Picked this up — shipping behind a flag this week."}'
{
  "comment": {
    "id": "ae13c9d0-2f76-4a38-bb54-908e7c1d5f62",
    "body": "Picked this up — shipping behind a flag this week.",
    "isOfficial": true,
    "originalLang": "en",
    "bodyI18n": null,
    "authorName": "Mei Tanaka",
    "createdAt": "2026-07-14T08:22:19.771Z"
  }
}

The language is detected from the text, and translation (on a project that enables it) runs after the response, so bodyI18n is null in the create response even when a translation is on its way.

POST/workspaces/{workspace}/projects/{project}/requests/{number}/vote

Vote, or withdraw a vote, as the token's owner.

Body fieldTypeNotes
onbooleanOptional. true to vote, false to withdraw. Omit the field — or send no body at all — to toggle.

Prefer the absolute form in anything that might retry: {"on": true}is idempotent and lands on “voted” however many times it arrives, whereas a repeated toggle bounces straight back off.

curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/37/vote" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"on": true}'
{
  "voted": true,
  "voteCount": 13
}

This response is not wrapped in an object key — voted and voteCount are at the top level. votedis the caller's state after the call; voteCountis the request's fresh total.

GET/workspaces/{workspace}/projects/{project}/tags

The project's tag palette, alphabetical by name. This is how you turn a tag name into the id that ?tag= and the attach/detach paths need.

curl -sS "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/tags" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "tags": [
    {
      "id": "c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246",
      "name": "regression",
      "color": "seal"
    },
    {
      "id": "f0e91b74-5c23-4d8a-96b1-0a7d4e2c8351",
      "name": "ux",
      "color": "akari"
    }
  ]
}

POST/workspaces/{workspace}/projects/{project}/tags

Add a tag to the project's palette. Owner/admin only. Responds 201; a name already on the project is a 409 with the key tagExists.

Body fieldTypeNotes
namestringRequired. Trimmed; 1–30 characters. Unique within the project.
colorstringOptional. A palette name, not a hex value — see Vocabularies. One of: wood, akari, seal, bamboo, sumi. Defaults to wood.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/tags" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "regression", "color": "seal"}'
{
  "tag": {
    "id": "c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246",
    "name": "regression",
    "color": "seal"
  }
}

Deleting a tag is not part of this surface: it would detach the tag from every request carrying it, which is a project-administration action and belongs in the UI.

PUT/workspaces/{workspace}/projects/{project}/requests/{number}/tags/{tagId}

DELETE/workspaces/{workspace}/projects/{project}/requests/{number}/tags/{tagId}

Attach (PUT) or detach (DELETE) one of the project's tags. Owner/admin only. Both take no body, both answer 204 with no body, and both are idempotent — re-applying either is a no-op rather than an error.

{tagId} is a tag id, not a name, and the tag must belong to the same project as the request; otherwise 400 with the key tagWrongProject.

# attach
curl -sS -X PUT "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/42/tags/c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"

# detach
curl -sS -X DELETE "$ZUMINO_URL/api/v1/workspaces/acme/projects/feedback/requests/42/tags/c47a0d38-91e6-4b2f-8a5d-3e7b1c9f0246" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"

Silence is success. Read the request back if you want to see its new tag list.

Outsourcings

A work project handing a set of its items to a manager — an agent — to finish on runners. A project record rather than an item: it has no code, is named by its project and number (“JapanLaw #2”), and is addressed by that number. Anyone who can see the project reads it; opening and changing one needs outsourcing.manage, which owners and admins hold and the Automations add-on grants, and write access to the project. An item is held by at most one live outsourcing: holding one another holds answers 409 outsourcingItemHeld, naming the holder when you can see it.

FieldTypeNotes
numbernumberIts number in its project — the 2 in "JapanLaw #2", and the {number} in its path.
labelstringHow a person names it: the project's name and its number.
titlestring | null
statusstringactive and paused are live and hold their items; done and stopped are over. One of: active, paused, done, stopped.
mergePolicystring | nullask, allowed or never; null follows the project's default.
agentOutsourcingPerson | nullThe agent its runs act as; null follows the project's default.
effectiveMergePolicystring | nullThe merge policy it runs under: mergePolicy when it has its own, and otherwise its project's outsourcing profile's, as that stands now. Null only when its project has no profile.
effectiveAgentOutsourcingPerson | nullThe agent its runs act as: agent when it names one, and otherwise its project's outsourcing profile's. Null when neither names one.
createdByOutsourcingPerson | null
heldCountnumberHow many items it holds now that the caller may see — the same items items lists.
needsInputbooleanNeeds you*: a question on its timeline has no answer yet. A message that answers it clears this once no other question waits.
createdAtstring
updatedAtstring
endedAtstring | nullWhen it became done or stopped; null while live.
goalstring | nullMarkdown: what it is for.
itemsHeldItem[]
planPlanSummary | nullNull until the manager writes one.
costobjectWhat an outsourcing's runs spent, as each run's ending recorded it. A manager turn counts its own figure — its difference from the turn before — never its session's running total.

GET/workspaces/{workspace}/projects/{project}/outsourcings

POST/workspaces/{workspace}/projects/{project}/outsourcings

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}

PATCH/workspaces/{workspace}/projects/{project}/outsourcings/{number}

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}/plan

PUT/workspaces/{workspace}/projects/{project}/outsourcings/{number}/plan

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}/items

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/items

DELETE/workspaces/{workspace}/projects/{project}/outsourcings/{number}/items/{code}

Body fieldTypeNotes
taskstringOptional. Outsource one task, by its code — ACME-14.
epicstringOptional. Outsource an epic, by its code — ACME-E3: the epic and every open task under it are held.
itemsstring[]Optional. Outsource a hand-picked set, by code — tasks and epics, from any project of this workspace, held exactly as named (an epic is not expanded). At most 200.
titlestring | nullOptional. What to call it. Optional — an outsourcing is known by its number, "JapanLaw #2". Left out on creation, it is the task's or epic's own title when that item is in this project. Empty or null clears it.
goalstring | nullOptional. Markdown: what the person outsourcing it wants out of it. Handed to the manager. Empty or null clears it.
mergePolicystring | nullOptional. Whether finished work may reach the default branch without a person: ask — a person says so each time; allowed — the manager may merge; never — pull requests only. Null is the project's default.
agentIdstring | nullOptional. The agent its runs act as — one of this workspace's agents that the caller may manage (agents.manage, within the caller's reach). Null for the project's default.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/app/outsourcings" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" -H "Content-Type: application/json" \
  -d '{"epic": "APP-E3", "goal": "Ship the importer", "mergePolicy": "ask"}'

A project’s outsourcing profile

A project says once how its outsourcings run: the repository and ref its runs check out, the labels a runner must carry to take one, the harness, model and effort, the agent runs act as, the merge policy, and the project’s own playbook, if it has one. Until a project has one, opening an outsourcing there answers 409 outsourcingProfileMissing. An outsourcing with no merge policy or agent of its own follows the profile’s as it stands — its effectiveMergePolicy and effectiveAgent say what that is now. Reading it needs outsourcing.manage; setting it needs project.manage, and a PUT changes only the fields it names.

FieldTypeNotes
repoUrlstringThe repository every run checks out — what git clone takes.
defaultRefstring | nullThe branch, tag or commit a run's branch is cut from; null is the repository's default branch.
runnerLabelsstring[]Only runners labelled: labels a runner must carry, every one, to take one of its runs — the owner's intent, not what the machine is. Empty: any runner.
runnerOsstring[] | nullThe OS a runner must run, any one of these, as its capability document reports it. Null: any.
runnerArchstring[] | nullThe CPU architecture a runner must have, any one of these. Null: any.
runnerToolsstring[] | nullTools a runner must have usable — present, no error, logged in where they have a login — every one. Null: none. Reaching repoUrl is required too, of a runner that can say (source_probe), and is not a field.
harnessstring One of: claude, codex, opencode.
modelstring
effortstring | nullNull is the harness's own default.
agentOutsourcingPerson | nullThe agent runs act as when the outsourcing names none. Null: none chosen.
mergePolicystringask, allowed or never — what an outsourcing with no merge policy of its own follows. One of: ask, allowed, never.
playbookstring | nullThis project's own playbook, as markdown, replacing the workspace's and the built-in one whole for its runs. Null: none of its own — its runs are told the workspace's playbook (GET /workspaces/{workspace}/outsourcing-playbook), else the built-in one.
watcherModelstring | nullThe model the outsourcings' watcher runs (ZUM-164), as the profile names it. Null: the harness's cheap one, which effectiveWatcherModel names.
effectiveWatcherModelstringWhat the watcher runs now: watcherModel, else the harness's cheap one — a check every ten minutes that reads the outsourcing's pull requests and wakes the manager when something changed.
updatedByOutsourcingPerson | nullWho wrote it last.
createdAtstring
updatedAtstring

GET/workspaces/{workspace}/projects/{project}/outsourcing-profile

PUT/workspaces/{workspace}/projects/{project}/outsourcing-profile

Body fieldTypeNotes
repoUrlstringOptional. The repository every run checks out — what git clone takes: https://…, ssh://git@…, git@host:org/repo.git, or an absolute path on the runner. Never with a credential in it: a runner fetches with its own machine's.
defaultRefstring | nullOptional. The branch, tag or commit a run's own branch is cut from. Null or empty: the repository's default branch.
runnerLabelsstring[]Optional. Only runners labelled: a runner must carry *every* one, as with any run's labels — for the owner's intent (a tenant, a read-only machine), not for what the machine is, which runnerOs, runnerArch and runnerTools say. Up to 20, each up to 64 characters; repeats are dropped. Empty: any runner of the workspace.
runnerOsstring[] | nullOptional. The OS a runner must run, any one of: darwin, linux, windows — as the runner reports it. Null or empty: any.
runnerArchstring[] | nullOptional. The CPU architecture a runner must have, any one of: arm64, amd64. Null or empty: any.
runnerToolsstring[] | nullOptional. Tools a runner must have, every one usable — present, reporting no error, and logged in where it has a login: docker, browser, gh (browser: a Chrome, Chromium or Playwright browser a run can drive headless; gh: the GitHub CLI, signed in — the runner must report logged_in: true, which a worker opening a pull request needs). Null or empty: none.
harnessstringOptional. The agent harness its runs ask for, unless a run names another. One of: claude, codex, opencode.
modelstringOptional. The model the harness runs, as the harness names it. Left out on the first write, it is the harness's default — claude-opus-5-5 for claude. Left out when harness changes, it becomes the new harness's default if the stored model is another harness's catalog model; a name the catalog does not know stays.
effortstring | nullOptional. How hard the harness thinks: low, medium, high, xhigh, max, ultra, or null for the harness's own default. A catalog model may be asked only for a level it offers (automationEffortNotOffered).
agentIdstring | nullOptional. The agent runs act as when the outsourcing names none — one of this workspace's agents that the caller may manage (agentBeyondReach otherwise). Null: none.
mergePolicystringOptional. Whether finished work may reach the default branch without a person: ask (the default) — a person says so each time; allowed — the manager may merge; never — pull requests only. An outsourcing may override it. One of: ask, allowed, never.
playbookstring | nullOptional. This project's own playbook, as markdown — at most 20000 characters — replacing the workspace's and the built-in one whole for this project's runs. Null or empty: none of its own, so its runs are told the workspace's playbook, else the built-in one.
watcherModelstring | nullOptional. The model the outsourcings' *watcher* runs under harness — a check every ten minutes that reads the pull requests and wakes the manager when something changed. Null or empty: the harness's cheap one — claude-haiku-5-5 for claude, gpt-5.6-luna for codex.
curl -sS -X PUT "$ZUMINO_URL/api/v1/workspaces/acme/projects/app/outsourcing-profile" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" -H "Content-Type: application/json" \
  -d '{"repoUrl": "[email protected]:acme/app.git", "runnerLabels": ["linux"], "mergePolicy": "ask"}'

The playbook

Every run of an outsourcing is told a playbook right after the preamble: how the work is organised — batches, an integration branch when there are several, the runs that build, integrate and verify, review, evidence and the finish. It is the project’s own (the profile’s playbook), else the workspace’s, else Zumino’s built-in one; an override replaces whole, and clearing one falls back to the next. A repository adds to it with a section of its AGENTS.md. Reading the workspace’s needs outsourcing.manage; writing it, automations.manage.

GET/workspaces/{workspace}/outsourcing-playbook

PUT/workspaces/{workspace}/outsourcing-playbook

DELETE/workspaces/{workspace}/outsourcing-playbook

curl -sS -X PUT "$ZUMINO_URL/api/v1/workspaces/acme/outsourcing-playbook" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -Rs '{playbook: .}' playbook.md)"

An outsourcing’s timeline

Everything said, thought, decided and found is a note on the outsourcing’s timeline. A person writes messages; a run of the outsourcing, through its own token, also writes thought, decision, finding, question and report, and the note carries the run and its label from that token. Only Zumino writes events — a run started or ended, a held item moved, a plan version. A question sets the outsourcing’s needsInput, and it clears once no question is left unanswered — a message that answers one question leaves it set while another still waits. Files ride with the note as multipart/form-data, and anything shaped like a token is masked before it is stored.

FieldTypeNotes
idstringPass it as after to read on from here, or as answers to answer a question.
kindstring One of: message, thought, decision, finding, bug, question, report, event.
bodystringMarkdown. Credential-shaped text was masked before it was stored.
authorOutsourcingPerson | nullThe account that wrote it — a person, or the agent a run acts as. Null on an event.
viastring | nullweb or api, from the credential; null on an event, which Zumino wrote.
runobject | nullThe run whose token wrote it — or, on an event, the run it is about. Taken from the credential, never from what was sent.
notifybooleanIts writer thinks a person should know.
tostring[]Labels of the runs it is addressed to — manager for the manager; a run's notes hook shows these first. Sent as names or labels, stored as labels.
handoffbooleanThe manager's handoff — the latest is what a manager opened afresh from the record is given.
answersstring | nullThe question it answers.
itemsstring[]Codes of the items it is about that the caller can see.
eventNoteEvent | null
attachmentsAttachment[]
createdAtstring

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}/notes

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/notes

curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/app/outsourcings/2/notes" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -F kind=finding -F body="The importer drops rows past 10k." -F [email protected]

What a run is told

Every run of an outsourcing is told its served context: the preamble — how to work here, the same for every project — then the outsourcing as the run’s agent may read it: the goal, what it holds with live statuses, the plan or where to read it, findings and the latest notes, and the run itself. This renders it again, fresh, for the run named by run or by the token that asks.

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}/context

Pause, Resume, Stop and Done

The only limits Zumino enforces on how a manager works. Pause starts nothing new and wakes nobody, while live runs finish; Resume wakes the manager once with everything that waited. Stop cancels every live run, closes the manager’s session and releases what it held; Done ends it the same way once no run but the caller’s own is live (409 outsourcingRunsLive). Each answers the outsourcing, and asking for what already is changes nothing.

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/pause

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/resume

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/stop

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/done

Waking the manager

The manager is woken when something happens, and otherwise by a heartbeat — every 20 minutes while a run is live, every 2 hours while none is. Anything time-bound it asks for itself: a wake-up at a time (at), after a while (in, from 1m to 7d), or — given neither — now, with a one-line reason it is told when the wake-up falls due. A person who may manage the outsourcing, or a watcher, asks the same way. It is recorded on the timeline as a wake.scheduled event; a paused outsourcing keeps it until Resume, and an ended one answers 409 outsourcingEnded.

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/wake

Body fieldTypeNotes
instringOptional. How long from now — 15m, 2h, 1h30m, 1d, 1m30s — from 1m to 7d. At most one of in and at; neither means now.
atstringOptional. When, as ISO 8601 with its offset — 2026-10-19T13:45:00Z. In the future, and at most 7d ahead.
reasonstringRequired. Why, in one line of at most 500 characters — "nudge the B1 review", "PR #12 merged". Shown on the timeline, and said to the manager, as the asker's words, when the wake-up falls due.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/app/outsourcings/2/wake" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" -H "Content-Type: application/json" \
  -d '{"in":"30m","reason":"Nudge the B1 review if nobody has looked."}'

An outsourcing’s runs

Every run of an outsourcing — its manager’s and every worker’s — is a plain run on a runner, started here. It checks out the project’s repository at the refasked for or the profile’s, asks for the profile’s harness, model, effort and runner labels unless the body names its own, and acts as the outsourcing’s agent with ZUMINO_TOKEN, ZUMINO_URL, ZUMINO_WORKSPACE, ZUMINO_OUTSOURCING and ZUMINO_RUN— the token good for that run alone. Zumino knows a run’s free label and its parent, which is the run whose token asked: never something sent. Labels that leave out one the profile requires answer 400 runnerNotAllowed; a paused or ended outsourcing 409 outsourcingPaused or outsourcingEnded; another outsourcing’s run token 404. Nothing is queued when anything is refused.

FieldTypeNotes
idstringThe run's id in the hub — what ZUMINO_RUN holds inside it.
labelstringIts free label — "B1 build" — shown beside its name.
namestringThe name it goes by in the room: a human first name dealt to its label when the label first appeared — every run with the label has it, and no other label of the outsourcing does — or "Manager" for the manager's, "Watcher" for one of the watcher's checks. A note's to takes it as well as the label.
parentstring | nullThe run that started it, by id; null for one a person or Zumino started.
managerbooleanOne of the manager's turns; false for a worker.
watcherbooleanOne of the watcher's checks (ZUM-164) — never in GET …/runs, but read by its id. Neither the manager's nor a worker.
startedByOutsourcingPerson | nullThe account that asked — a person, or the agent a run acts as.
statestringWhere the run stands in the hub now. One of: queued, offered, claimed, preparing, running, waiting, succeeded, failed, cancelled, timed_out, lost.
reasonstring | nullThe runner's or the hub's word for why it is where it is, when there is one.
waitingobject | null
harnessstring
modelstring
effortstring | null
labelsstring[]Every label a runner must carry to take it.
runnerstring | nullThe runner it is offered to or held by — or, once it ended, its last holder.
createdAtstringWhen it was started, ISO 8601.
startedAtstring | nullWhen it began working — its first event, ISO 8601. Null while it waits for a runner, and for a run that never got to work.
endedAtstring | nullWhen it ended, ISO 8601; null while it is live.
usageobject | nullWhat it spent — its own figure: a manager turn's is its difference from the turn before, since Claude reports a session's running total. Null while it is live, until its ending is read, and when its runner reported nothing.

GET/workspaces/{workspace}/projects/{project}/outsourcings/{number}/runs

POST/workspaces/{workspace}/projects/{project}/outsourcings/{number}/runs

Body fieldTypeNotes
labelstringRequired. What the run is called — "B1 build", "verify APP-14": one line, at most 80 characters. The timeline shows it beside the human name the label is given the first time it is used — every run with this label goes by that name. Free text: Zumino never reads a kind of work into it.
briefstringRequired. What the run is to do, markdown, at most 20,000 characters — the run's instruction.
refstring | nullOptional. The branch, tag or commit the run's checkout starts from. Left out: the profile's defaultRef, or the repository's default branch.
labelsstring[]Optional. The labels a runner must carry, every one, to take the run — its whole set. Left out: the profile's runnerLabels. Given, it must contain every label the profile requires, and may add more; a set that leaves one out is refused runnerNotAllowed before anything is queued.
harnessstringOptional. The agent harness the run asks for. Left out: the profile's. One of: claude, codex, opencode.
modelstringOptional. The model, as the harness names it. Left out: the profile's — or, when harness names another harness, that harness's default model.
effortstring | nullOptional. How hard the harness thinks: low, medium, high, xhigh, max, ultra, or null for the harness's default. Left out: the profile's. A catalog model may be asked only for a level it offers (automationEffortNotOffered).
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/projects/app/outsourcings/2/runs" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" -H "Content-Type: application/json" \
  -d '{"label": "B1 build", "brief": "Build the importer for B1.", "ref": "main"}'

Automations

A workspace's standing instructions to its agents — when this happens, hand an agent this brief. They are workspace configuration rather than items: no code, no project, addressed by id. Every path needs automations.manage, which owners and admins hold and a member or an agent holds only with the Automations add-on; anyone else is answered 404, as if the workspace were not there.

FieldTypeNotes
idstringIts address: …/automations/{id}. Automations have no code and no number.
namestringWhat the workspace calls it.
enabledbooleanWhether its trigger fires. A disabled automation keeps its trigger and can still be run by hand.
triggerAutomationTrigger
harnessstringThe agent harness a runner must offer. One of: claude, codex, opencode.
modelstringThe model the harness runs, as the harness names it.
effortstring | nullHow hard the harness is asked to think; null for the harness's own default. A run naming one is offered only to a runner advertising the effort protocol feature.
labelsstring[]Labels every one of which a runner must carry — the owner's intent, not what the machine is, which runnerOs, runnerArch and runnerTools say.
runnerOsstring[] | nullThe OS a runner must run, any one of these, as its capability document reports it. Null: any.
runnerArchstring[] | nullThe CPU architecture a runner must have, any one of these. Null: any.
runnerToolsstring[] | nullTools a runner must have usable — present, no error, logged in where they have a login — every one. Null: none. Reaching each git source is required too, of a runner that can say (source_probe), and is not a field.
maxWaitMsnumberHow long a run may wait to be claimed before it ends timed_out, in milliseconds.
overlapstringWhat a firing does while an earlier run is still live. One of: queue, skip, replace.
sourcesAutomationSource[]What each run's session works in, in order. Given to a run that opens a session.
actsAsobject | nullThe agent each run acts as: the run is granted a ZUMINO_TOKEN for it, good for that run alone. Null: runs get no Zumino credential.
grantsWithheldobject[]What goes without on this server as it stands, always sealing_unavailable here. ZUMINO_TOKEN and ZUMINO_URL when it acts as an agent: its runs still go out, without them. SLACK_BOT_TOKEN for a Slack trigger means more than a missing token: *Slack cannot fire it at all*, because the server cannot check a Slack message without the key — and Run now is never given that token. Empty when nothing is withheld. A warning, not a refusal: it empties by itself once the server can seal.
createdAtstringISO-8601, UTC.
updatedAtstringISO-8601, UTC. Moves when an edit or a switch changes something, and only then.
instructionsstringMarkdown: what the agent is asked to do, every time it fires.

GET/workspaces/{workspace}/automations

POST/workspaces/{workspace}/automations

The trigger kind chosen here is the one it keeps. 201 with the automation, switched on unless the body says "enabled": false.

Body fieldTypeNotes
namestringRequired. What the list calls it. 1–100 characters.
enabledbooleanOptional. Whether its trigger fires. Only on creation — afterwards, …/enable and …/disable are the one writer of it. Defaults to true.
instructionsstringRequired. Markdown: what the agent is asked to do, every time it fires.
harnessstringOptional. Which agent harness a runner must offer. One of: claude, codex, opencode. Defaults to claude.
modelstringRequired. The model the harness runs, as the harness names it — a full name such as claude-opus-5-5 or an alias such as opus. Free: a name GET …/automations/models does not list is accepted, and the run fails on the runner if the harness does not know it.
effortstring | nullOptional. How hard the harness is asked to think: low, medium, high, xhigh, max, ultra, or null for the harness's own default. A model GET …/automations/models lists may be asked only for the levels it lists (automationEffortNotOffered); any other model, for any level. A run naming one is offered only to a runner that advertises the effort protocol feature, and waits for one otherwise. Defaults to null.
labelsstring[]Optional. Free-form requirements, every one of which a runner must carry. Up to 20, each up to 64 characters; repeats are dropped.
runnerOsstring[] | nullOptional. The OS a runner must run, any one of: darwin, linux, windows — as the runner reports it. Null or empty: any.
runnerArchstring[] | nullOptional. The CPU architecture a runner must have, any one of: arm64, amd64. Null or empty: any.
runnerToolsstring[] | nullOptional. Tools a runner must have, every one usable — present, reporting no error, and logged in where it has a login: docker, browser, gh (browser: a Chrome, Chromium or Playwright browser a run can drive headless; gh: the GitHub CLI, signed in — the runner must report logged_in: true, which a worker opening a pull request needs). Null or empty: none.
maxWaitMsnumberOptional. How long a run may wait for a runner to claim it before it ends timed_out, in milliseconds — one minute to one day.
overlapstringOptional. What a firing does while an earlier run is still live: queue beside it, skip (recorded as a skipped run), or replace it. One of: queue, skip, replace.
sourcesunknown[]Optional. What each run's session works in, in order — at most 5. Given to a run that opens a session; a run continuing one carries none.
actsAsUserIdstring | nullOptional. An agent of this workspace each run acts as: the run is granted a ZUMINO_TOKEN for it, good for that run alone. Null, a run gets no Zumino credential. Setting it needs agents.manage over that agent. Defaults to null.
triggerunknownRequired. What makes it fire. The kind is chosen here and never changes; firing it another way is another automation.
curl -sS -X POST "$ZUMINO_URL/api/v1/workspaces/acme/automations" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Morning triage", "model": "opus",
       "instructions": "Triage the requests that came in overnight.",
       "trigger": {"kind": "schedule", "cron": "0 9 * * 1-5", "timeZone": "Asia/Tokyo"}}'

GET/workspaces/{workspace}/automations/{automation}

PATCH/workspaces/{workspace}/automations/{automation}

Only what the body names changes, and trigger is merged into what the trigger has — {"trigger": {"cron": "30 8 * * *"}} keeps the time zone. The result is checked whole, by the rules a new automation is held to.

Body fieldTypeNotes
namestringOptional.
instructionsstringOptional. Markdown: what the agent is asked to do, every time it fires.
harnessstringOptional. Which agent harness a runner must offer. One of: claude, codex, opencode.
modelstringOptional. The model the harness runs, as the harness names it — a full name such as claude-opus-5-5 or an alias such as opus. Free: a name GET …/automations/models does not list is accepted, and the run fails on the runner if the harness does not know it.
effortstring | nullOptional. A level, or null for the harness's default.
labelsstring[]Optional. Free-form requirements, every one of which a runner must carry. Up to 20, each up to 64 characters; repeats are dropped.
runnerOsstring[] | nullOptional. The OS a runner must run, any one of: darwin, linux, windows — as the runner reports it. Null or empty: any.
runnerArchstring[] | nullOptional. The CPU architecture a runner must have, any one of: arm64, amd64. Null or empty: any.
runnerToolsstring[] | nullOptional. Tools a runner must have, every one usable — present, reporting no error, and logged in where it has a login: docker, browser, gh (browser: a Chrome, Chromium or Playwright browser a run can drive headless; gh: the GitHub CLI, signed in — the runner must report logged_in: true, which a worker opening a pull request needs). Null or empty: none.
maxWaitMsnumberOptional. How long a run may wait for a runner to claim it before it ends timed_out, in milliseconds — one minute to one day.
overlapstringOptional. What a firing does while an earlier run is still live: queue beside it, skip (recorded as a skipped run), or replace it. One of: queue, skip, replace.
sourcesunknown[]Optional. The whole list, replacing what it had. [] clears it.
actsAsUserIdstring | nullOptional. An agent of this workspace each run acts as: the run is granted a ZUMINO_TOKEN for it, good for that run alone. Null, a run gets no Zumino credential. Setting it needs agents.manage over that agent.
triggerobjectOptional. The trigger's conditions to change — merged into what it has, so {"cron": "0 8 * * 1-5"} keeps its time zone. A list given replaces the list it had.

POST/workspaces/{workspace}/automations/{automation}/enable

POST/workspaces/{workspace}/automations/{automation}/disable

The one writer of enabled once an automation exists. No body; both answer the automation, and both are safe to repeat.

GET/workspaces/{workspace}/automations/integrations

The ids a Slack trigger's integrationId takes, with their names. Never a credential: connecting an integration is done in a browser, and its secrets are never shown again.

Runs

A run's idis its firing's: one per time an automation fired, a skipped one included — that one has state skipped and run: null. The hub's run, when there is one, is nested as run: its brief, requirements, holder and result. Never its grants.

FieldTypeNotes
idstringThe run's address — …/automations/runs/{id} — and its page's in the app. It is the firing's id: one per time the automation fired.
automationobjectThe automation that fired.
statestringWhere it is. The hub's states, plus skipped for a firing its overlap recorded without submitting anything. One of: queued, offered, claimed, preparing, running, waiting, succeeded, failed, cancelled, timed_out, lost, skipped.
firedBystringmanual when someone pressed Run now; trigger when its trigger fired. One of: trigger, manual.
triggerstring | nullWhat fired it: manual for a press, whatever the automation's trigger, otherwise the kind of the event it stored. Null for an event of a shape this release does not know.
bystring | nullWho is behind it, as the event recorded them — the person who pressed Run now, or who wrote the Slack message. Null for a schedule.
createdAtstringWhen it fired. ISO-8601, UTC.
endedAtstring | nullWhen it ended — for a skipped one, when it fired. Null while it is live.
outcomestringsubmitted a run to the hub, or skipped by its overlap. One of: submitted, skipped.
runRun | nullWhat was submitted — null exactly when it was skipped.
grantsWithheldobject[] | nullWhat it was meant to carry and went without, and why — never a value, only names; ZUMINO_TOKEN and ZUMINO_URL go together. Empty when it was given everything; null when it was skipped, or fired before Zumino recorded this.

POST/workspaces/{workspace}/automations/{automation}/run

Run now. 201 with the run; every press is its own, so a retry fires again. Spends the fire bucket below.

GET/workspaces/{workspace}/automations/runs

QueryTypeNotes
automationstringOne automation's runs, by its id.
statestringOne state. A value this release does not know matches nothing. One of: queued, offered, claimed, preparing, running, waiting, succeeded, failed, cancelled, timed_out, lost, skipped.
triggerstringWhat fired it — manual for a press, whatever the automation's trigger. A value this release does not know matches nothing. One of: manual, schedule, slack, mattermost.
livestringtrue for runs not yet ended, false for ended ones. A skipped run is never live. One of: true, false.
limitnumberRows per page, 1–200; default 50.
offsetnumberRows to skip — page with it. total counts every match.

GET/workspaces/{workspace}/automations/runs/{run}

POST/workspaces/{workspace}/automations/runs/{run}/cancel

GET/workspaces/{workspace}/automations/runs/{run}/events

A long poll. Ask with after and waitMs; the answer comes at once when there is something new, or after waitMs with nothing. Ask again with after = nextAfter until done, which is set only once the run is terminal and every event through its result has been returned.

QueryTypeNotes
afternumberThe last seq already read; 0, the default, for the start.
waitMsnumberHow long to hold the call when there is nothing to answer, in milliseconds: 0 (the default) answers at once, and anything above 50000 is held 50000.
after=0
while :; do
  page=$(curl -sS "$ZUMINO_URL/api/v1/workspaces/acme/automations/runs/$RUN/events?after=$after&waitMs=50000" \
    -H "Authorization: Bearer $ZUMINO_TOKEN")
  echo "$page" | jq -c '.events[]'
  after=$(echo "$page" | jq .nextAfter)
  [ "$(echo "$page" | jq .done)" = true ] && break
done

Rate limits

Every write is metered per user — not per token — so minting a second token does not buy a second budget. Reads are not limited.

ActionLimit
POST …/requests25 per 10 minutes
POST …/comments75 per 10 minutes
every other write to a request — vote, a tag attached or detached, request patch, internal note, answer500 per 10 minutes
POST …/projects/{project}/tags — creating a tag writes the project rather than any request, so it spends a budget of its own on either kind of project500 per 10 minutes
every write to an automation — create, edit, enable, disable100 per 10 minutes
every write to the workspace's runners — a registration token, a drain, a revoke60 per 10 minutes
POST …/automations/{automation}/run — Run now, which hands work to a runner rather than writing a row30 per 10 minutes
POST …/tasks, and POST …/tasks/batch — which charges one per task, not one per call100 per 10 minutes

Exceeding a limit returns 429 with the key rateLimited:

{
  "error": {
    "key": "rateLimited",
    "message": "Too many requests. Try again later."
  }
}

The response carries a Retry-After header: the whole seconds until your window resets. Sleep for it and repeat the same request rather than skipping it — a half-finished write is worse than a slow one. Do not retry in a tight loop; the budget is spent before the body is validated, so a flood of malformed requests counts too.

POST …/tasks/batch is the exception, and has to be: at one unit per task a malformed batch would cost fifty, so it validates first and a 4xx there is free.

Requests and comments carry the tightest two because each one can trigger a background translation, and those are the same ceilings the web composer enforces. All of them are set to stop a runaway loop rather than to pace a caller doing real work — but bulk-importing a backlog through this API is still not what it is for.

Use from a terminal, a script, or an agent

There is no client library to install and nothing here is generated into one. What there is instead is @zumino/cli, a thin front-end over the endpoints on this page, plus an agent skill that teaches Claude Code and anything else with a shell what the words mean.

npm i -g @zumino/cli
# or
curl -fsSL https://zumino.cc/install.sh | sh

Every endpoint stays reachable, including ones added after you installed it — zumino api GET /queue takes any method and path below /api/v1. What the CLI adds that curl cannot is a version handshake: every response carries the oldest CLI this server will answer and the newest one published, so a stale client is told on the call it was already making, and an incompatible one refuses to run rather than returning a plausible answer built on shapes this server no longer sends.

Set it up

zumino auth login --host https://zumino.cc   # paste a token from /app/account
zumino init              # in a repo — ties it to a project
zumino skill install     # the agent skill, once, globally

zumino auth login cannot create a token, deliberately: a credential authenticates as its owner, so one that could mint more would turn a leak into permanent access that revoking the leaked token does not undo. You create one in a browser you are signed in to; the CLI reads it, checks it, and remembers which host and account it belongs to.

zumino init writes a committed .zumino.json — host, workspace and project, and no secret — so every command in that checkout needs no arguments and an agent working there never has to be told where its work is filed.

Find your way around it

The command list is not written down twice: one declaration in the package (cli/src/spec.js) is what the help pages, the argument validation and the machine-readable dump are all generated from. So a flag cannot be documented and unvalidated, or accepted and undocumented.

zumino                       # every command, grouped, one line each
zumino help task list        # one command in full: flags, accepted values, examples
zumino commands --json       # the whole surface, for a program to read

zumino commands --json is the one to read from a script. It carries every command and flag, and every value each filter accepts under values — so --status in_review is never discovered by having --status review refused.

Every filter the CLI offers is a filter these endpoints perform. Nothing is narrowed locally, because a client-side filter reads exactly like a server-side one and is wrong invisibly: it filters the page it was handed rather than the project. A flag a command does not accept is therefore an error naming the command that does have it, and a paged answer says so and names the flag that fetches the rest.

zumino task list --status todo,in_progress --priority high --assignee me
zumino request list --status open --sort top --limit 10
zumino find "rate limit" --kind task,epic --state open
zumino task events ACME-14 --field status    # how has this moved

Which account a command acts as

There is no stored “current account” and no switchcommand. One person may hold several accounts across several hosts, and two agents may be running in two checkouts at once — a single mutable pointer in a home directory would let either silently change the other's identity, and a write landing as the wrong person looks exactly like success. Context is resolved from the process's own environment and the directory it is standing in, in this order:

1. --token / --account / --host / --project
2. ZUMINO_TOKEN / ZUMINO_URL / ZUMINO_PROJECT / ZUMINO_WORKSPACE
3. .zumino.json in the repo
4. the repo map in ~/.config/zumino/config.json
5. the only account configured
6. refuse

zumino auth status prints which of those answered. Use a read-only token if you want to guarantee an agent can read and summarise but never change anything — the CLI checks that up front rather than discovering it through a 403.

Exit codes

Branch on these rather than parsing messages: 0 fine, 1 the call failed, 2 nothing resolved an account, host or project, 3 the CLI is older than this server allows — which is the one that means zumino self-update — and 4, from zumino run watch, a run that ended and did not succeed. Notices go to stderr and answers go to stdout, so zumino queue --json | jq is always safe.

Start with one project and one agent.

Invite your team, connect a machine, and give an agent something you have already written down.

Zumino comes from zumi, the Japanese word for done. The seal on finished work says it.