Todo
Package todo is Hanzo Todo: boards, the work items on them, and the filters that make a board.
Package todo is Hanzo Todo: boards, the work items on them, and the filters that make a board.
| Base URL | https://api.hanzo.ai |
| Operations | 12 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1160 · Todo — The Work Item Board — Draft · read the specification →
/v1/todo is the one work-item surface: boards, the issues on them, and the
filters that make a board. Its defining fact is that the forge is the store —
a board is a repository on the deployment's forge and an issue is that
repository's issue — so every read is a read of the forge and nothing mirrors
it. The implementation is hanzoai/cloud apps/todo.
Motivation
Hanzo has three planes for state that changes over time, and the boundary
between them is law (apps/todo/contract.go): the todo Issue is the one
work-item primitive, framework.DocType holds schema-defined domain records,
and hanzoai/tasks runs async execution. Every work-item surface — a board, a
repo's Issues tab, an agent's queue — is a filter over the one Issue, never a
parallel store. Without that rule each tool grows its own issues table, and two
tables are two answers to what the state of the work is.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The forge is the store
The forge (Forgejo, git.hanzo.ai) is the single source of truth for boards
and issues: a todo project is a repository, an issue is its issue, a board
column is a label from the closed status set, a deadline is a milestone's due
date (apps/todo/source.go). Nothing here MAY mirror, cache-as-table, or
write through: a second copy would drift the first time anyone touched the
forge directly, which is every day.
What the capability owns locally is only what the forge cannot answer: a
per-(org, project) SQLite index opened through cloud.OrgStore
(apps/todo/todo.go:109), holding the org-wide index every external source
lands in — which is what makes "is anyone tracking X" a question with an
answer. The store's on-disk name stays tracker (apps/todo/todo.go:137)
because the name is a key binding: cek derives each file's encryption key from
it, so renaming it re-keys every tenant's database and opens a blank board
beside the old rows. The rows keep the spelling they were written under.
§2 The address
Every route is under /v1/todo (manifest/apps.go:177). Twelve operations;
nine are typed. The three that are not — POST, PATCH and DELETE on
/v1/todo/projects[/{key}] — answer a bare 405 with prose declared beside the
route (apps/todo/todo.go): project lifecycle is named at the forge, and an
operation whose only answer is a refusal has no shape to type.
Two more doors carry the same upsert across process boundaries, on the
internal plane rather than /v1: the cloud.IssueSink a co-resident feeder
calls, and the plane op /todo/upsert for the integrations process that holds
the GitHub App webhook (apps/todo/upsert_plane.go). Both are idempotent by
ExtRef, so a webhook redelivery updates the row it created rather than
duplicating it.
§3 Tenancy is two independent controls
The org is the validated principal's (principal.OrgFrom /
principal.Acting) and MUST NOT be read from a path, query or body. That is
control one. Control two is the forge's own ACL: every forge call drops
privilege to the requesting user (forge.Client.As), so the deployment's
machine token cannot read an org the user could not read anyway, and a defect
in control one cannot leak a private repository on its own
(apps/todo/source.go:30). On the plane upsert the tenant is the caller's
plane identity — plane.IssueIn deliberately has no org field.
The claim operation's holder is the caller, never an argument: "assign to someone else" is a different act with different authority and already exists as PATCH. A claim on an issue someone else holds is refused, not silently won.
§4 Money
The surface declares cloud.Metered (plugin/todo/main.go) and is listed in
the standing gate (spend.go:318, "per-project/issue fee"), so a write
requires standing before it runs; reads are never billable. The fee itself
defaults to zero — charging per issue is the wrong product — and an operator
prices it per deployment via CLOUD_TODO_FEE_CENTS, debited through the
shared per-org resource meter (apps/todo/todo.go:100-107).
§5 Events and telemetry
It publishes nothing on the bus, so a customer's webhooks receive no
todo.* events. Beyond the request span every route gets, it emits only its
own log lines; no additional spans or metrics.
§6 Upstream
It forks and embeds nothing. It is a client of the deployment's Forgejo forge,
speaking that forge's REST dialect, and the recipient of GitHub issues the
integrations feeder mirrors in through the sink in §2. The per-tenant index
rides github.com/hanzoai/sqlite (MIT / Apache-2.0 dual).
§7 Stage
beta: a vertical application (a project board), not part of the self-service
agentic-OS core. The manifest row (manifest/apps.go:177) does not yet
declare it, so today the operations serve as ga does; the row's
Stage: Beta is the one edit that closes the drift (HIP-0139 §8).
Rationale
The alternative to reading the forge is the usual one: a local issues table with a sync job. It answers faster and it is how the two-answers defect gets in — an engineer who relabels in the forge web UI has, on this design, moved the card, because the column is the label. A status column in a table here could not have that property, and keeping it true would mean a reconciler that is itself a third answer.
Security Considerations
The exposure is a cross-tenant board read, and it is closed twice over (§3): the org is never caller-supplied, and even a bug there runs into the forge's own per-user ACL because the request acts as the user, not as the machine token. The wrong implementation — one credential, org from the request — hands an attacker every private org's issues on the forge through a single confused deputy. The claim rule is the other one: a claim that names its holder would let anyone hand work to anyone by naming them.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | todo at its own prefix | 12 operations |
| CLI | hanzo todo … | 12 of 12 |
| SDK | TodoApi in every published client | 12 methods |
| MCP | tool todo on https://api.hanzo.ai/v1/mcp | 12 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/todo/board, operation get_todo_board:
hanzo todo boardimport { Configuration, TodoApi } from 'hanzoai';
const api = new TodoApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getTodoBoard();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import TodoApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = TodoApi(client).get_todo_board()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.TodoAPI.GetTodoBoard(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, todo_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = todo_api::get_todo_board(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.TodoApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new TodoApi(client).getTodoBoard();curl https://api.hanzo.ai/v1/todo/board \
-H "Authorization: Bearer $HANZO_API_KEY"Tool todo, op get_todo_board — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "todo",
"arguments": {
"op": "get_todo_board",
"input": {}
}
}
}'Answers 200 with issueView[] — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/todo/board | Returns a board's issues — work items with their column, priority, assignee, labels and schedule. |
GET /v1/todo/issues | Answers across every project in the org. |
POST /v1/todo/projects/{key}/issues/{num}/claim | Takes an issue: it becomes yours and it moves to in_progress. |
GET /v1/todo/projects/{key}/issues/{num} | Returns ONE work item in full — its description included. |
PATCH /v1/todo/projects/{key}/issues/{num} | Edits a work item — rename it, rewrite it, move it to another column, or re-prioritise it. |
GET /v1/todo/projects/{key}/issues | Returns a board's issues — work items with their column, priority, assignee, labels and schedule. |
POST /v1/todo/projects/{key}/issues | Opens a work item on the board — an issue on that repository on the deployment's forge, filed as YOU. |
GET /v1/todo/projects/{key} | Returns one board of your org by its key — the repository name. |
PATCH /v1/todo/projects/{key} | Refused — a board is a repository on the forge |
DELETE /v1/todo/projects/{key} | Refused — a board is a repository on the forge |
GET /v1/todo/projects | Returns the boards of your org — the places your work actually is. |
POST /v1/todo/projects | Refused — a board is a repository on the forge |
How is this guide?