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/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/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 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 board 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 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.
| Action | Who |
|---|---|
| Read a board and its entries | Anyone for a public board; members only for a private one |
| Create an entry, comment, vote | Anyone who can read the board |
Edit an entry'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 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 numberErrors
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
| Status | Meaning |
|---|---|
200 | Success. |
201 | Created — an entry, comment, or tag now exists. |
204 | Success, no body. Only tag attach and detach answer this. |
400 | The request was malformed, failed validation, or exceeded a rate limit. |
401 | No usable token. |
403 | Authenticated, but not allowed — including any write with a read-only token. |
404 | No such board, entry, tag, or workspace — or one you may not see. |
409 | Conflict with something that already exists. |
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 | notEntryEditor | Editing the content of someone else's entry without moderator rights. |
403 | notMember | Not a member of the workspace that owns the board. |
404 | notMember | From GET /boards?workspace=…: no workspace of yours has that slug or id. |
404 | boardNotFound | No board with that publicSlug or id — or one you may not see. |
404 | entryNotFound | No entry with that number on the board. Also returned as 400 when the segment is not a positive integer. |
404 | tagNotFound | No tag with that id. |
409 | tagExists | The board already has a tag with that name. |
400 | tagWrongBoard | That tag id belongs to a different board than the entry does. |
400 | titleTooShort | Entry title is under 3 characters after trimming. |
400 | titleTooLong | Entry title is over 140 characters. |
400 | bodyTooLong | An entry 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. |
400 | rateLimited | A per-user creation limit was hit. See Rate limits. |
500 | generic | Server 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
| 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. |
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.
| 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 board collapses them into a “Closed” section below the live list.
Board 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 board 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 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.
| Field | Type | Notes |
|---|---|---|
id | string | Accepted anywhere {board} is, alongside the slug. |
publicSlug | string | null | The board key: globally unique and permanent, so it is safe to hard-code. Null only on a legacy board that predates slugs. |
name | string | Display name. |
description | string | null | Board blurb, or null. |
visibility | string | How the board is reached — see Vocabularies. One of: public, private. |
language | string | The locale the board 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. |
entryCount | number | How many entries the board holds. List view only. |
workspace | WorkspaceRef | The 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.
| Field | Type | Notes |
|---|---|---|
number | number | The entry's key within its board (the #42 shown on the board). Not an id, and not unique across boards. |
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 board does not auto-translate, the original is already in the board'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 entries 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 entry, or null when it has none. |
createdAt | string | ISO-8601. |
tags | Tag[] | The tags on this entry. Empty array when none. |
hasVoted | boolean | Whether the calling token's owner has voted on this entry. |
updatedAt | string | ISO-8601. Detail view only. |
closedAt | string | null | When the entry 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 board. |
color | string | A palette name — see Vocabularies. Not a hex value. |
Comment
| Field | Type | Notes |
|---|---|---|
id | string | Stable — useful for de-duplicating across polls. No endpoint takes it. |
body | string | The author's original text. |
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 |
|---|---|---|
url | string | Where the file is served from. |
name | string | Original filename. |
type | string | MIME type, e.g. image/png. |
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/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.
| 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/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.
| Query | Type | Notes |
|---|---|---|
status | string | Repeatable (?status=open&status=planned) or comma-separated (?status=open,planned). Omit for all statuses. One of: open, planned, in_progress, done, wont_do. |
type | string | One entry type. One of: feature, bug, improvement, idea. |
tag | string | A tag id (not a name), from GET /boards/{board}/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 entries 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/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 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 entry 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 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 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. 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/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 field | Type | Notes |
|---|---|---|
body | string | Required. 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 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/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 field | Type | Notes |
|---|---|---|
name | string | Required. Trimmed; 1–30 characters. Unique within the board. |
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/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.
| Action | Limit |
|---|---|
POST …/entries | 10 per 10 minutes |
POST …/comments | 30 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:
| Variable | Value |
|---|---|
ZUMINO_URL | Your instance's origin — no trailing slash, and no /api path. The skill appends /api/public/v1 itself. |
ZUMINO_TOKEN | A 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.