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/v1Every 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.0.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 a400, never a500. - 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 is201; tag attach and detach are204with 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 a personal access token, sent as a bearer token:
Authorization: Bearer zumino_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA 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.
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 needAdmin. 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.
| Action | Who |
|---|---|
| Read a project and its requests | Anyone for a public project; members only for a private one |
| Create a request, comment, vote | Anyone who can read the project |
Edit a request's title, description, type | Its author, or a workspace owner/admin |
Set status or pinned; create tags; attach or detach tags | Workspace 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 numberCodes: 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.
Status codes
| Status | Meaning |
|---|---|
200 | Success. |
201 | Created — a request, comment, or tag now exists. |
204 | Success, no body. Only tag attach and detach answer this. |
400 | The request was malformed or failed validation. A rate limit is 429, never this. |
401 | No usable token. |
403 | Authenticated, but not allowed — including any write with a read-only token. |
404 | No such project, request, tag, or workspace — or one you may not see. |
409 | Conflict with something that already exists. |
429 | A per-user write limit was hit. It carries a Retry-After header — sleep for it and repeat the same call. See Rate limits. |
500 | A fault on our side. The key is always generic and the message carries no detail. |
Error keys
| Status | Key | What it means |
|---|---|---|
401 | authRequired | Token missing, malformed, unknown, expired, or revoked. Mint a new one at /app/account. |
403 | needAdmin | A write attempted with a read-only token, or an action that requires workspace owner/admin (status, pin, tag management). |
403 | notRequestEditor | Editing the content of someone else's request without moderator rights. |
403 | notMember | Not a member of the workspace that owns the project. |
404 | notMember | From GET /projects?workspace=…: no workspace of yours has that slug or id. |
404 | projectNotFound | No 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. |
404 | requestNotFound | No request with that number on the project. Also returned as 400 when the segment is not a positive integer. |
404 | tagNotFound | No tag with that id. |
409 | tagExists | The project already has a tag with that name. |
400 | tagWrongProject | That tag id belongs to a different project than the request does. |
400 | titleTooShort | Request title is under 3 characters after trimming. |
400 | titleTooLong | Request title is over 140 characters. |
400 | bodyTooLong | A request description or comment body is over 5000 characters. |
400 | commentEmpty | Comment body is empty after trimming. |
400 | tagNameRequired | Tag name is empty after trimming. |
400 | tagNameTooLong | Tag name is over 30 characters. |
400 | invalidInput | The body was not valid JSON, or failed a generic check. |
429 | rateLimited | A per-user creation limit was hit. Wait and repeat the call. See Rate limits. |
500 | generic | Server 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
| Value | Meaning |
|---|---|
feature | A request for something new. The default on create. |
bug | Something is broken. |
improvement | Something exists but should be better. |
idea | A 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.
| Value | Meaning |
|---|---|
open | Received, not yet triaged. The default on create. |
planned | Accepted and intended. |
in_progress | Being worked on now. |
done | Shipped. A closed status. |
wont_do | Declined. 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
| Value | Meaning |
|---|---|
public | Readable by anyone at /b/{publicSlug}. |
private | Readable 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.
| Value | Appearance |
|---|---|
wood | Warm brown. The default. |
akari | Amber — the brand colour. |
seal | Vermilion. |
bamboo | Pale warm sand. |
sumi | Muted 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.
| Field | Type | Notes |
|---|---|---|
id | string | Stable id. |
kind | string | Which 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. |
code | string | null | How 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}. |
number | number | The item's key within its project and its kind — what …/tasks/{number} takes. A task and an epic may share a number. |
title | string | The author's original text. |
description | string | null | The preview — what a list row shows. Capped; the long-form writing on a task is spec.plan, read from the task's own endpoint. |
authorName | string | null | The public author label. Null for anonymous or nameless authors — never an email address or user id. |
assignee | Person | null | The accountable person — always a human, even when an agent does the work. Null when nobody is assigned, and on a kind that never assigns. |
commentCount | number | Total comments. |
state | string | Derived 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. |
closedAt | string | null | When the item was closed; null while it is still live. |
createdAt | string | ISO-8601. |
updatedAt | string | ISO-8601. |
project | object | The project holding the item. Present on the project-scoped read too, so one row means the same thing wherever it was found. |
path | string | The 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
| Field | Type | Notes |
|---|---|---|
slug | string | The {workspace} path segment, and what ?workspace= filters on. |
name | string | Display name. |
avatarUrl | string | null | The 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. |
role | string | The *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. One of: owner, admin, member. |
Project
The last two fields appear only in GET /projects; GET /projects/{project} returns the project without them.
| Field | Type | Notes |
|---|---|---|
id | string | Stable id. |
slug | string | What {project} in a path takes. Unique within the workspace, which is why every path names one. |
publicSlug | string | null | Where 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. |
name | string | Display name. |
description | string | null | Project blurb, or null. |
type | string | What 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. |
key | string | null | The 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. |
visibility | string | How the project is reached — see Vocabularies. One of: public, private. |
language | string | The locale the project is read and managed in, e.g. en. |
autoTranslate | boolean | When true, content written in another language is translated into language in the background after it is posted. |
createdAt | string | ISO-8601. |
updatedAt | string | ISO-8601. |
requestCount | number | How many requests the project holds — always 0 on a work project. List view only. |
taskCount | number | How many tasks the project holds — always 0 on a feedback project. List view only. |
workspace | WorkspaceRef | The 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.
| Field | Type | Notes |
|---|---|---|
number | number | The 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. |
title | string | The author's original text. |
description | string | null | The author's original text, or null. |
originalLang | string | The locale the author wrote in, e.g. "ja". |
titleI18n | object | null | Translations 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. |
descriptionI18n | object | null | Same shape, for the description. |
type | string | See Vocabularies. One of: feature, bug, improvement, idea. |
status | string | See Vocabularies. One of: open, planned, in_progress, done, wont_do. |
voteCount | number | Total votes. |
commentCount | number | Total comments. |
pinned | boolean | Pinned requests sort first, ahead of any sort order. |
authorName | string | null | The public author label. Null for anonymous or nameless authors — never an email address or user id. |
attachments | Attachment[] | null | Files on the request, or null when it has none. |
createdAt | string | ISO-8601. |
tags | Tag[] | The tags on this request. Empty array when none. |
hasVoted | boolean | Whether the calling token's owner has voted on this request. |
updatedAt | string | ISO-8601. Detail view only. |
closedAt | string | null | When the request reached done or wont_do; null while it is still live. Detail view only. |
Tag
| Field | Type | Notes |
|---|---|---|
id | string | Used in ?tag= and in the tag attach/detach paths. Not the name. |
name | string | Unique within the project. |
color | string | A palette name — see Vocabularies. Not a hex value. |
Comment
| Field | Type | Notes |
|---|---|---|
id | string | Stable — useful for de-duplicating across polls, and what PUT …/requests/{number}/answer names. |
body | string | The author's original text. |
isOfficial | boolean | Whether 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. |
originalLang | string | The locale it was written in. |
bodyI18n | object | null | Translations keyed by locale, or null. |
authorName | string | null | Public author label, or null. |
createdAt | string | ISO-8601. |
Attachment
| Field | Type | Notes |
|---|---|---|
id | string | Stable id. Pass it to DELETE …/attachments/{id} to remove the file. |
url | string | Path 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. |
name | string | Original filename. |
type | string | MIME type, e.g. image/png. Decided from the file's own bytes at upload time, not from what the uploader declared. |
size | number | Bytes. |
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"
},
"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.
| Query | Type | Notes |
|---|---|---|
kind | string[] | 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. |
assignee | string | A user id. assignee is on the spine precisely so that “what is assigned to me across every project and every kind” is one call. |
state | string | open or closed, from closedAt. Not a status — the kind's own endpoint has that. One of: open, closed. |
q | string | Substring of the title or description, case-insensitive. |
sort | string | updated (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. |
offset | number | Rows to skip. Default 0. |
limit | number | Page 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.
| Query | Type | Notes |
|---|---|---|
workspace | string | Narrow 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. |
project | string | Narrow 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. |
needsInput | boolean | true 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. |
limit | number | Clamped 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.
| Query | Type | Notes |
|---|---|---|
status | string[] | 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. |
type | string | One task type. One of: feature, bug, improvement, refactor, investigation, chore. |
priority | string | One priority. One of: critical, high, normal, low. |
assignee | string | A user id. |
needsInput | boolean | true keeps only tasks flagged as waiting on a person. Defaults to false. |
open | boolean | true hides closed tasks (done, wont_do). Defaults to false. |
tag | string | A tag id (not a name), from GET …/projects/{project}/tags. One tag at a time. |
epic | string | An 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. |
q | string | Case-insensitive substring match against the title or description. |
sort | string | priority (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. |
offset | number | Negative values are treated as 0. Defaults to 0. |
limit | number | Clamped 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 field | Type | Notes |
|---|---|---|
tasks | TaskBatchItem[] | Required. The tasks to file, in the order they should be numbered. At least one, at most 50. |
Each task in that array:
| Field | Type | Notes |
|---|---|---|
title | string | The only required field — capture has to be cheap. |
description | string | null | What 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. |
type | string | One of: feature, bug, improvement, refactor, investigation, chore. |
priority | string | One of: critical, high, normal, low. |
status | string | Defaults to backlog. Must exist on the board's type. One of: backlog, todo, shaping, in_progress, in_review, done, wont_do. |
size | string | null | |
clarity | string | null | |
assigneeId | string | null | |
epicNumber | number | null | The epic's number on this board, or null to detach. Not an id. |
spec | object | null | Both 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.
| Query | Type | Notes |
|---|---|---|
workspace | string | Narrow 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",
"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",
"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.
| Query | Type | Notes |
|---|---|---|
status | string[] | 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. |
type | string | One request type. One of: feature, bug, improvement, idea. |
tag | string | A tag id (not a name), from GET …/projects/{project}/tags. One tag at a time. |
q | string | Case-insensitive substring match against the title or description. |
sort | string | top 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. |
limit | number | Clamped to 1–200, however large a value is sent. Defaults to 50. |
offset | number | Negative 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. Open to anyone who can read the project, under a per-user rate limit. Responds 201.
| Body field | Type | Notes |
|---|---|---|
title | string | Required. Trimmed; 3–140 characters. |
description | string | Optional. Trimmed; up to 5000 characters. |
type | string | Optional. What kind of thing the request is — see Vocabularies. One of: feature, bug, improvement, idea. Defaults to feature. |
sourceLang | string | Optional. The language you are writing in. Omit and it is detected from the text. One of: en, ja, ru. |
translate | boolean | Optional. 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 field | Type | Notes |
|---|---|---|
title | string | Optional. Trimmed; 3–140 characters. Author or owner/admin. |
description | string | null | Optional. Trimmed; up to 5000 characters. null clears it. Author or owner/admin. |
type | string | Optional. Author or owner/admin. One of: feature, bug, improvement, idea. |
status | string | Optional. 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. |
pinned | boolean | Optional. 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 field | Type | Notes |
|---|---|---|
body | string | Required. 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 field | Type | Notes |
|---|---|---|
on | boolean | Optional. 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 field | Type | Notes |
|---|---|---|
name | string | Required. Trimmed; 1–30 characters. Unique within the project. |
color | string | Optional. 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.
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.
| Action | Limit |
|---|---|
POST …/requests | 25 per 10 minutes |
POST …/comments | 75 per 10 minutes |
| every other write to a request — vote, a tag attached or detached, request patch, internal note, answer | 500 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 project | 500 per 10 minutes |
POST …/tasks, and POST …/tasks/batch — which charges one per task, not one per call | 100 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 | shEvery 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, globallyzumino 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 readzumino 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 movedWhich 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. refusezumino 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, and 3 the CLI is older than this server allows — which is the one that means zumino self-update. Notices go to stderr and answers go to stdout, so zumino queue --json | jq is always safe.