API Reference

Last updated July 19, 2026

Overview

The Zumino API reads and writes board content over HTTP: boards, entries, 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/public/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/public/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/public/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/public/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 board 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 board. 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 boards 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 board and its entriesAnyone for a public board; members only for a private one
Create an entry, comment, voteAnyone who can read the board
Edit an entry's title, description, typeIts author, or a workspace owner/admin
Set status or pinned; create tags; attach or detach tagsWorkspace owner/admin only

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

Addressing boards and entries

Boards: the publicSlug

The {board} path segment is the board's publicSlug — for example /boards/acme-feedback/entries. The slug is:

  • globally unique, not unique-per-workspace, so it identifies a board on its own;
  • permanent. Renaming a board does not change it, which is what makes it safe to hard-code in a script;
  • present on private boards too. A slug is not a statement that a board is public — visibility is enforced separately, on every request.

The board's id is accepted in the same position, so an id returned by the API can be fed straight back into a URL. That also covers the one case where publicSlug is null: a legacy board created before slugs existed. Use GET /boards to discover both.

Entries: the number

The {number}segment is the entry's per-board sequence number — the #42 shown on the board — and it is not the entry's id. Numbers restart at 1 on every board, so #42 is only meaningful together with a board key.

Entry ids are not published at all: no public endpoint accepts one, so none is returned. A path segment that is not a positive integer is a 400 with the key entryNotFound.

GET /boards/acme-feedback/entries/42
          ^ publicSlug          ^ per-board number

Errors

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

{
  "error": {
    "key": "boardNotFound",
    "message": "Board 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 — an entry, comment, or tag now exists.
204Success, no body. Only tag attach and detach answer this.
400The request was malformed, failed validation, or exceeded a rate limit.
401No usable token.
403Authenticated, but not allowed — including any write with a read-only token.
404No such board, entry, tag, or workspace — or one you may not see.
409Conflict with something that already exists.
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).
403notEntryEditorEditing the content of someone else's entry without moderator rights.
403notMemberNot a member of the workspace that owns the board.
404notMemberFrom GET /boards?workspace=…: no workspace of yours has that slug or id.
404boardNotFoundNo board with that publicSlug or id — or one you may not see.
404entryNotFoundNo entry with that number on the board. Also returned as 400 when the segment is not a positive integer.
404tagNotFoundNo tag with that id.
409tagExistsThe board already has a tag with that name.
400tagWrongBoardThat tag id belongs to a different board than the entry does.
400titleTooShortEntry title is under 3 characters after trimming.
400titleTooLongEntry title is over 140 characters.
400bodyTooLongAn entry 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.
400rateLimitedA per-user creation limit was hit. See Rate limits.
500genericServer fault. Report it; do not retry in a loop.

One caveat on 400s: a value that fails a check with no dedicated key — sending "type": "task", say, or a non-boolean pinned — returns a descriptive sentence in key instead of one of the identifiers above. Branch on the status for those, and on the key for the ones listed here.

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.

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

Entry 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 board collapses them into a “Closed” section below the live list.

Board 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 board 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 board's language, as an entry or comment's originalLang, and as the keys of the *I18n maps.

Objects

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

Board

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

FieldTypeNotes
idstringAccepted anywhere {board} is, alongside the slug.
publicSlugstring | nullThe board key: globally unique and permanent, so it is safe to hard-code. Null only on a legacy board that predates slugs.
namestringDisplay name.
descriptionstring | nullBoard blurb, or null.
visibilitystringHow the board is reached — see Vocabularies. One of: public, private.
languagestringThe locale the board 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.
entryCountnumberHow many entries the board holds. List view only.
workspaceWorkspaceRefThe workspace that owns the board — its slug and name, and nothing else. List view only.

Entry

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

FieldTypeNotes
numbernumberThe entry's key within its board (the #42 shown on the board). Not an id, and not unique across boards.
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 board does not auto-translate, the original is already in the board'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 entries 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 entry, or null when it has none.
createdAtstringISO-8601.
tagsTag[]The tags on this entry. Empty array when none.
hasVotedbooleanWhether the calling token's owner has voted on this entry.
updatedAtstringISO-8601. Detail view only.
closedAtstring | nullWhen the entry 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 board.
colorstringA palette name — see Vocabularies. Not a hex value.

Comment

FieldTypeNotes
idstringStable — useful for de-duplicating across polls. No endpoint takes it.
bodystringThe author's original text.
originalLangstringThe locale it was written in.
bodyI18nobject | nullTranslations keyed by locale, or null.
authorNamestring | nullPublic author label, or null.
createdAtstringISO-8601.

Attachment

FieldTypeNotes
urlstringWhere the file is served from.
namestringOriginal filename.
typestringMIME type, e.g. image/png.
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/public/v1/me" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "user": {
    "id": "8f2c1b6a-4d3e-4c9a-9b71-2e5d0a7c1f34",
    "name": "Mei Tanaka",
    "email": "[email protected]"
  },
  "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/boards

Every board the caller can reach, across all their workspaces, oldest workspace first and board 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/public/v1/boards?workspace=acme" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "boards": [
    {
      "id": "3b9f7e21-8c04-4a15-bd62-7f0c9a4e8d13",
      "publicSlug": "acme-feedback",
      "name": "Acme Feedback",
      "description": "Feature requests and bug reports for Acme.",
      "visibility": "public",
      "language": "en",
      "autoTranslate": true,
      "createdAt": "2026-03-02T09:14:22.517Z",
      "updatedAt": "2026-07-11T04:41:08.902Z",
      "entryCount": 128,
      "workspace": {
        "slug": "acme",
        "name": "Acme"
      }
    }
  ]
}

There is no endpoint that enumerates workspaces, and none that creates, updates, or deletes a board: those administer a workspace rather than operate on board content, and stay in the web UI.

GET/boards/{board}

One board and its counters.

curl -sS "$ZUMINO_URL/api/public/v1/boards/acme-feedback" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "board": {
    "id": "3b9f7e21-8c04-4a15-bd62-7f0c9a4e8d13",
    "publicSlug": "acme-feedback",
    "name": "Acme Feedback",
    "description": "Feature requests and bug reports for Acme.",
    "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
    }
  }
}

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

GET/boards/{board}/entries

A page of the board's entries, plus the total number matching the filters before pagination.

QueryTypeNotes
statusstringRepeatable (?status=open&status=planned) or comma-separated (?status=open,planned). Omit for all statuses. One of: open, planned, in_progress, done, wont_do.
typestringOne entry type. One of: feature, bug, improvement, idea.
tagstringA tag id (not a name), from GET /boards/{board}/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 entries 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/public/v1/boards/acme-feedback/entries?status=open,planned&sort=top&limit=2" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "entries": [
    {
      "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": [
        {
          "url": "https://zumino.example.com/uploads/9f1c2a.png",
          "name": "screenshot.png",
          "type": "image/png",
          "size": 184320
        }
      ],
      "createdAt": "2026-06-19T02:35:11.004Z",
      "tags": [],
      "hasVoted": false
    }
  ],
  "total": 92
}

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

GET/boards/{board}/entries/{number}

One entry with its comments, oldest first. Tags and the caller's vote state ride inside entry, 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/public/v1/boards/acme-feedback/entries/42" \
  -H "Authorization: Bearer $ZUMINO_TOKEN"
{
  "entry": {
    "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.",
      "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.",
      "originalLang": "en",
      "bodyI18n": null,
      "authorName": "Mei Tanaka",
      "createdAt": "2026-07-14T08:22:19.771Z"
    }
  ]
}

POST/boards/{board}/entries

File an entry as the token's owner. Open to anyone who can read the board, 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 entry 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 entry on a board that has it enabled.
curl -sS -X POST "$ZUMINO_URL/api/public/v1/boards/acme-feedback/entries" \
  -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"
  }'
{
  "entry": {
    "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 entry.number is available immediately — that is the handle for every follow-up call. Translation, when the board enables it, runs after the response is sent, so titleI18n is null here and filled in moments later.

PATCH/boards/{board}/entries/{number}

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

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. 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/public/v1/boards/acme-feedback/entries/42" \
  -H "Authorization: Bearer $ZUMINO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "in_progress"}'
{
  "entry": {
    "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 an entry is not part of this surface. Close it with status: "wont_do" instead, which keeps the conversation and the entry number intact.

POST/boards/{board}/entries/{number}/comments

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

Body fieldTypeNotes
bodystringRequired. Trimmed; 1–5000 characters.
curl -sS -X POST "$ZUMINO_URL/api/public/v1/boards/acme-feedback/entries/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.",
    "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 board that enables it) runs after the response, so bodyI18n is null in the create response even when a translation is on its way.

POST/boards/{board}/entries/{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/public/v1/boards/acme-feedback/entries/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 entry's fresh total.

GET/boards/{board}/tags

The board'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/public/v1/boards/acme-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/boards/{board}/tags

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

Body fieldTypeNotes
namestringRequired. Trimmed; 1–30 characters. Unique within the board.
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/public/v1/boards/acme-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 entry carrying it, which is a board-administration action and belongs in the UI.

PUT/boards/{board}/entries/{number}/tags/{tagId}

DELETE/boards/{board}/entries/{number}/tags/{tagId}

Attach (PUT) or detach (DELETE) one of the board'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 board as the entry; otherwise 400 with the key tagWrongBoard.

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

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

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

Rate limits

Creating entries and comments is metered per user — not per token — so minting a second token does not buy a second budget. Reads are not limited.

ActionLimit
POST …/entries10 per 10 minutes
POST …/comments30 per 10 minutes

Exceeding a limit returns 400, not 429, with the key rateLimited:

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

There is no Retry-After header. Back off for several minutes; do not retry in a tight loop. The budget is spent before the body is validated, so a flood of malformed requests counts too.

These are the same limits the web composer enforces, and they exist because each new entry and comment can trigger a background translation. Bulk-importing a backlog through this API is not what it is for — ten entries per ten minutes is the ceiling.

Use with Claude Code

Zumino ships an agent skill that teaches Claude Codeto drive this API directly. With it installed you can say “file that as a bug on the Acme board” or “triage the open queue” and the agent makes the calls itself — reading entries, filing them with real context from the repository it is working in, commenting, tagging, and moving items between statuses.

The skill is documentation, not code: it is a single Markdown file that describes the endpoints, the vocabularies, the error keys, and a handful of workflows. It calls the API with curl and reads the JSON with jq — there is nothing to install and no client library, and it works from any directory, not only inside a Zumino checkout.

Install

The skill lives at .claude/skills/zumino/SKILL.md in the Zumino repository. Copy it into your personal skills directory to make it available in every project:

mkdir -p ~/.claude/skills/zumino
cp path/to/zumino/.claude/skills/zumino/SKILL.md ~/.claude/skills/zumino/

To scope it to one project instead, copy it to .claude/skills/zumino/SKILL.md inside that project and commit it. Either way, Claude Code picks it up on the next session.

Configure

The skill reads two environment variables from the shell Claude Code is running in:

VariableValue
ZUMINO_URLYour instance's origin — no trailing slash, and no /api path. The skill appends /api/public/v1 itself.
ZUMINO_TOKENA personal access token from /app/account.
# ~/.zshrc
export ZUMINO_URL="https://zumino.example.com"
export ZUMINO_TOKEN="zumino_live_…"

Put them in your shell profile or a secret manager — not in a .env file inside a repository. The skill will tell you if either is missing rather than guessing, and it is instructed never to invent or reconstruct a token.

What it will and will not do

The skill is written to be safe to hand a board. It confirms who the token acts as before writing anything, checks token.readOnly up front rather than discovering it through a 403, shows you any text it is about to post in your name, and asks before anything destructive — detaching a tag included. Use a read-only token if you want to guarantee that, in which case the agent can read and summarise boards but never change them.