API Reference

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.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 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 a personal access token, sent as a bearer token:

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.

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.

ActionWho
Read a project and its requestsAnyone for a public project; members only for a private one
Create a request, comment, voteAnyone who can read the project
Edit a request's title, description, typeIts author, 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.

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. 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.
403needAdminA write attempted with a read-only token, or an action that requires workspace owner/admin (status, pin, tag management).
403notRequestEditorEditing the content of someone else's request without moderator rights.
403notMemberNot a member of the workspace that owns the project.
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 — the brand colour.
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. One of: owner, admin, member.

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.
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"
  },
  "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",
      "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.

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. Open to anyone who can read the project, 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.

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
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, 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.