- Python 3.12 / FastAPI 0.141.1 / SQLite(Python 同梱の
sqlite3)/ htmx 2.0.10 / Windows 11 で書いています。macOS/Linux では動かしていません - 記事は 2 本立てです。今回は Web アプリが動くまで。次回、ここに MCP サーバーを生やして Claude Code から書き込みます
- 画面に写っている項目・ワークスペース名・AI の名前
ai:demo-assistantは、すべてこの記事のために用意した架空のデータです。同じ画面はリポジトリのダミーデータで再現できます - ソースは GitHub の
ai-taskboard(MIT)に全文があります。本文では要点だけを引用し、行番号は v1.0.0 のものです
🧭 はじめに:残項目がチャットの末尾に散らばる
Claude Code に仕事を頼むと、セッションの終わりに「残項目」が並びます。次の日、別のセッションで続きを頼むと、その残項目はもう見えません。仕方なく人間がメモ帳に書き写す。担当を分けた AI が複数いれば、それぞれの完了報告の末尾にも残項目が生えている。AI 側のメモリに「続報を追う」と書いてある案件もあって、それは人間からは一覧で見えない。
つまり、こうなります。
| 今の置き場 | 何が入っているか | 困りごと |
|---|---|---|
| チャットの会話 | セッション末尾の「残項目」まとめ | セッションが切り替わると探せない。人間が別の場所へ書き写している |
| タスクごとの完了報告 | 各担当の AI が書く「残課題」欄 | タスク単位に分かれていて横断できない。片付いたかどうかを誰も更新しない |
| AI 側のメモリ | 続報ウォッチ・企画のタネ | AI の記憶であって、人間が一覧で見る画面が無い |
欲しかったのは、人と AI が同じ台帳に書ける場所です。人はブラウザで見て書き、AI は自分の道具(ツール)で同じ項目を読み書きする。そして、後から「これは誰がいつ書いたのか」が全部たどれること。
作ったものの全体像はこうです。今回作るのは実線の部分で、点線の入口は次回です。
ブラウザ"] -->|"フォーム POST + htmx"| UI["Web UI
/w/blog"] AI["Claude Code
(次回)"] -.->|"stdio"| MCP["MCP サーバー
(次回)"] OT["他の AI・スクリプト"] -.->|"HTTP"| REST["REST /api/v1"] UI --> SVC["service.py
唯一の書き込み経路
author と event を必ず残す"] MCP -.-> SVC REST -.-> SVC SVC --> DB[("SQLite 1 ファイル(WAL)
workspace / item / note / event")]
入口が 3 つあっても、書き込みは全部 service.py を通ります。誰がどこから書いても、同じ形の履歴が残る。この 1 点を守るために、あとの設計はほぼ決まりました。
前作の MCP サーバーの作り方と同じく、ハードウェアは使いません。uv が入った PC が 1 台あれば、最後まで手元で動きます。
🤔 なぜ既製のタスク管理ではなく作るのか
タスク管理ツールは世の中に山ほどあります。それでも作ったのは、譲れない条件が 3 つあったからです。
- AI が書ける口がある。しかも「誰が書いたか」が人と AI で区別されて残る
- ページを分けられて、ページごとに AI からの見え方を変えられる。ブログの候補は AI に整理させたいが、仕事の項目は AI に渡したくない
- ローカルで完結する。仕事の項目をクラウドに置かない。バックアップはファイルのコピー 1 回
この 3 つで既製ツールを見ると、こうなります。
| 案 | AI が書ける口 | ページ分割と AI 権限 | ローカル完結 | 見送った理由 |
|---|---|---|---|---|
| Notion(API) | ○ | △ | ✗ | 仕事の項目をクラウドに置くことになる |
| Trello(API) | ○ | △ | ✗ | 同上。author を ai:* で残す仕組みは自前で被せる必要がある |
| GitHub Projects | ○(GraphQL) | △ | ✗ | 開発項目には向くが、ブログ候補や仕事の項目を GitHub に置く理由が無い |
| Obsidian/Markdown+git | △ | ○ | ○ | 人間には快適だが、AI が状態と履歴を構造化して書く口が弱い |
| 自作 | ○ | ○ | ○ | この記事 |
Markdown ファイル+git は最後まで迷いました。決め手は「状態を変えた」という操作を構造化された履歴として残したかったことです。テキストの差分からは「誰が候補を着手に動かしたのか」は読み取れません。
ページ=ワークスペース、と AI 権限の 3 段階
このアプリでは、ページ(URL)がそのままワークスペースです。/w/blog はブログ、/w/work は仕事、というように、項目にタグを付けて分類するのではなく、ボードそのものを分けます。
分ける理由は AI からの見え方を変えるためで、ワークスペースごとに ai_policy を 3 段階から選びます。
| 値 | AI(MCP/REST)から | 使いどころ |
|---|---|---|
read_write |
読める・書ける | ブログの候補・工作室の残項目 |
read_only |
読めるが書けない | AI に要約はさせたいが、勝手に書かせたくないページ |
hidden |
存在しないものとして扱う。一覧に出ず、ID を指定しても「見つからない」 | 仕事の項目 |
人間のブラウザからは、この値に関係なく常に全部見えます。初回起動で作られる 3 つのうち「仕事」は既定で hidden です。
hidden が「アクセス拒否」ではなく「存在しない」なのは、拒否の応答そのものが「そこに何かある」という情報になるからです。AI が一覧を取っても出てこないし、ID を総当たりしても「見つからない」しか返りません。
ただし、アプリが守れるのはAI が自分で読みに行けないことだけです。人間が仕事の項目をチャットに貼れば、当然 AI に渡ります。境界はアプリではなく、人間の手元にあります。
この 3 値は DB のレベルで固定しています。テーブル定義の CHECK 制約なので、アプリのバグでも 4 つ目の値は入りません(src/taskboard/migrations/0001_init.sql L7-8)。
ai_policy TEXT NOT NULL DEFAULT 'read_write'
CHECK (ai_policy IN ('read_write','read_only','hidden')),
ワークスペース一覧。右上のバッジが ai_policy。「読書」は AI から読み取りのみ(ダミーデータ)
🧱 技術選定:ビルド工程ゼロで動く組み合わせ
1 人で使う CRUD アプリに、フロントエンドのビルド工程を持ち込みたくありませんでした。テンプレートを直して再読み込みしたら反映される、その速さを優先しています。
| 部品 | 版 | 採る理由 |
|---|---|---|
| FastAPI | 0.141.1 | 型で入力検証・OpenAPI が自動で出る。次回の MCP と同じ Pydantic モデルを流用できる |
| SQLite | Python 同梱の sqlite3 |
1 ファイル=バックアップはコピー 1 回。Web と MCP の 2 プロセスからの同時アクセスは WAL で足りる |
| htmx | 2.0.10 | ビルド工程なしで部分更新ができる。1 ファイルを static/ に同梱してオフラインでも動く |
| Jinja2 | 3.1.6 | FastAPI 公式が案内するテンプレート |
| markdown-it-py | 4.2.0 | 本文とノートの Markdown 描画。生 HTML を無効にできる |
| uv | — | uv run で仮想環境の有効化なしに起動。Python 3.12 も uv が用意する |
見送った案も並べておきます。「もっと速く作れる」ものは、それぞれ別の理由で外れました。
| 案 | 見送った理由 |
|---|---|
| Streamlit | 画面は速いが、URL で項目を直接開く・フォーム POST・部分更新に向かない。AI が「ここを見て」と返す URL を人間が踏む運用と相性が悪い |
| SPA(React/Vue+API) | ビルド工程と依存が増える。1 人用の CRUD に見合わない |
| Django | 管理画面は魅力だが、MCP と Pydantic モデルを共有する点で FastAPI のほうが素直 |
| Flask | FastAPI との差は OpenAPI 自動生成と型検証。REST を他の AI に渡す前提なので FastAPI |
| Node(Express/Hono) | 前作の MCP チュートリアルが Python なので、続編としての連続性を優先 |
| PostgreSQL 等のサーバー DB | 1 ユーザー・ローカルで運用コストに見合わない |
| htmx 4.0 | 2026-08-28 にリリース済みだが、htmx.org は 2.x 系の利用者が誤って上げないよう npm の latest にしていない、と案内している(2026-09-12 時点)。今回は 2.0.10 |
htmx は、HTML の属性だけでサーバーとやり取りを書く JavaScript ライブラリです。
hx-post="/items/12/move" を付けた要素が操作されると、その URL に POST し、返ってきた HTML の断片で hx-target に指定した要素を差し替えます。JSON を返して JS 側で DOM を組み立てる SPA と違い、サーバーは最初から HTML を返します。
うれしいのは、ページ全体を返すテンプレートと、断片を返すテンプレートを同じ部品にできることです。かんばん全体を描く _board.html を、初回表示では board.html から include し、htmx からの POST にはそれ単体で返す。JS を書かずに「そこだけ更新」ができます。
動かすまで
手順はこれだけです。python が PATH に無くても構いません。uv が .python-version を見て 3.12 を用意します。
git clone https://github.com/ramtuc/ai-taskboard.git
cd ai-taskboard
uv sync # 初回のみ。uv.lock どおりの版が入る
uv run taskboard seed --demo # ダミーデータを data/demo.sqlite3 に投入
$env:TASKBOARD_DB = "data/demo.sqlite3"; uv run taskboard serve
http://127.0.0.1:8765/ を開けば、上のスクリーンショットと同じ画面が出ます。serve は 127.0.0.1 にしかバインドしません。--host オプションは、あえて作っていません。
🖥️ 画面:ワークスペース → ボード → 詳細
画面は 3 段です。一覧からボードへ、ボードから項目の詳細へ。書き込みはすべてフォーム POST で、htmx が乗っていれば部分更新、乗っていなければ 303 で元の画面に戻ります。
件数と AI からの見え方"] --> B["/w/blog ボード
かんばん 6 列/リスト
クイック追加は部分更新"] W --> N["/workspaces/new
ワークスペース追加"] B --> D["/w/blog/items/1 項目の詳細
本文・ノート・履歴
編集・移動・ノートも部分更新"] B --> S["/w/blog/settings 設定
名前・説明・ai_policy"]
かんばんの列は、そのまま項目の状態です。6 つに固定しています。
| 列 | status |
意味 |
|---|---|---|
| 候補 | candidate |
やるかもしれないこと。AI が追加した項目もまずここに入る |
| 着手 | doing |
動いている |
| 待ち(人) | waiting_human |
人間の判断や作業を待っている。AI がここへ動かして人間に渡す |
| 待ち(AI) | waiting_ai |
AI の作業を待っている。人間がここへ戻す |
| 完了 | done |
completed_at が打たれる。列には直近 20 件だけ表示 |
| 保留 | hold |
削除の代わり |
「待ち(人)」と「待ち(AI)」を分けたのは、このボードの用途そのものです。人と AI が交互に動く仕事では、今どちらのターンかがいちばん知りたい情報になります。
かんばん表示。カード左上のバッジが作成者(AI は青・human は灰)。列を動かすのはカード下のセレクト(ダミーデータ)
?view=list でリスト表示。状態の絞り込みはリストだけに付けている(ダミーデータ)
カードの状態を変えられるのは、「状態を変更」のセレクトただ 1 か所です。編集フォームには状態の欄がありません。理由は次の節の履歴にあって、状態変更を必ず item.moved として記録したいからです。フォーム編集で状態を変えられると、その経路だけ「前後の値と理由」が残らなくなります。
削除機能もありません。項目は「保留」列へ、ワークスペースはアーカイブで隠します。履歴を消さない、という方針の帰結です。
⚡ htmx で JS を書かずに列が動く
いちばん気持ちよかったのがここです。カードを別の列へ動かすコードは、セレクトに属性を 4 つ付けただけです。
<form hx-post="/items/{{ item.id }}/move" hx-target="#board" hx-swap="outerHTML"
method="post" action="/items/{{ item.id }}/move">
<select name="status" hx-trigger="change" hx-post="/items/{{ item.id }}/move"
hx-target="#board" hx-swap="outerHTML" hx-include="closest form">
{% for s in STATUSES %}<option value="{{ s }}" {% if s == item.status %}selected{% endif %}>{{ STATUS_LABELS[s] }}</option>{% endfor %}
</select>
<noscript><button type="submit">移動</button></noscript>
</form>
(src/taskboard/templates/_macros.html L18-25 の move_select マクロから、絞り込みの引き継ぎを省いたもの。省いた部分は後述の罠に関係します)
hx-trigger="change"— セレクトを変えた瞬間に送る。ボタンは要らないhx-post— 送り先。普通のフォームのactionと同じ URLhx-target="#board"/hx-swap="outerHTML"— 返ってきた HTML で、かんばん全体(<section id="board">)を丸ごと差し替える<noscript>の送信ボタン — JS が無い環境では普通のフォームとして動く。methodとactionはそのために付けてある
流れを図にするとこうです。
ヘッダに HX-Request: true
HX-Target: board"] B --> C["service.move_item()
UPDATE item + INSERT event"] C --> D{"HX-Target は?"} D -->|"board か detail"| E["対応する部分テンプレートだけ描画
_board.html か _detail.html"] D -->|"無し(JS オフのフォーム)"| G["303 で元の画面へ"] E -->|"hx-swap=outerHTML"| S["ブラウザは id=board を差し替える
ページは再読み込みしない"]
サーバー側は「どこに差し込むか」を見て応答を選ぶ
htmx は POST に HX-Request: true と、差し替え先の id を HX-Target ヘッダで付けてきます。サーバーはこれを見て、部分テンプレートを返すか、303 で戻すかを選びます(src/taskboard/routes/items.py L32-54 の _after_write)。
def _after_write(request: Request, conn: sqlite3.Connection, item: m.Item, form: dict):
"""書き込み後の応答: htmx なら差し替え先に合わせた部分、それ以外は 303。"""
target = hx_target(request) if is_htmx(request) else ""
ws = service.get_workspace_by_id(conn, item.workspace_id)
if target == "board":
ctx = board_context(conn, ws, view=form.get("view") or "kanban", ...)
return render(request, "_board.html", ctx)
if target == "detail":
return render(request, "_detail.html", _detail_context(conn, item))
back = form.get("back") or f"/w/{ws.slug}"
return RedirectResponse(back, status_code=303)
同じ URL /items/{id}/move が、かんばんからの操作にはかんばんの断片を、詳細画面からの操作には詳細の断片を、JS 無しのフォームには 303 を返します。エンドポイントは 1 本、テンプレートは 2 枚、JS は 0 行。クイック追加もノート追加も完了ボタンも、全部この型です。
差し替え先を #board 全体にしているのは手抜きではなく、列の件数表示・「残り n 件をリストで見る」リンク・列内の並び順まで、ひとつのカード移動で全部変わるからです。カード 1 枚だけ差し替えて整合を取るより、かんばん全体を描き直すほうが単純で、規模的にも速度は問題になりません。
クイック追加の直後。候補列に #20 が増えているが、ページは再読み込みされていない(ダミーデータ)
セレクトを『着手』に変えた直後。候補 (2)→(1)、着手 (2)→(3)。かんばん全体が差し替わっている(ダミーデータ)
「再読み込みされていない」ことは目で見ても分からないので、headless Chrome で window にマーカーを置いてから操作し、操作後もマーカーが残っていれば部分更新、消えていればフルリロード、という判定にしています(docs/screenshots/RESULTS.json)。
自前の JS は、これだけ
htmx は 4xx/5xx の応答を差し込みません。サーバーが同じ状態への移動を 409 で弾いても、画面には何も起きない。そこで、エラー本文を画面上部に 6 秒だけ出す小さなハンドラを 1 つ書きました(src/taskboard/templates/base.html L20-27)。
<script>
// htmx は 4xx/5xx を差し込まないので、サーバーの短いエラー文を #flash に出す(唯一の自前 JS)
document.body.addEventListener('htmx:responseError', function (e) {
var f = document.getElementById('flash');
f.textContent = (e.detail.xhr.status + ': ' + e.detail.xhr.responseText).slice(0, 300);
f.hidden = false;
setTimeout(function () { f.hidden = true; }, 6000);
});
</script>
アプリ全体で、自分で書いた JavaScript はこの 1 ブロックです。サーバー側も、htmx からの要求でエラーになったときは HTML ページではなく短い本文だけを返すようにしています(app.py の _error_response)。
リスト表示には「状態: すべて/候補/着手…」の絞り込みがあり、そのセレクトの名前は status です。一方、カードを動かす POST が送る新しい状態も status。
カード移動のあとも絞り込みを保ちたいので、素直に書くと移動フォームに hx-include="#filters" を付けて絞り込みフォームの値を同送することになります。すると 1 つの POST に status が 2 つ乗る。「候補で絞り込んだリストで、カードを着手へ動かす」と、サーバーが受け取る status はどちらか分からなくなります。
回避は 2 段です(_board.html L3-4・_macros.html L19, L21)。
hx-includeの選択子で絞り込み側のstatusを除外する:#filters [name]:not([name=status])- 絞り込みの状態は、かんばんと一緒に再描画される隠し要素
#status-filter(name="status_filter")で別名にして運ぶ
hx-include は CSS 選択子なので、:not() がそのまま効きます。属性だけで書けるのは楽ですが、同名のフォーム値が別の意味で混ざるのは、複数フォームを 1 リクエストにまとめる htmx ならではの事故でした。
🧾 誰が書いたかが全部残る
このアプリの中心は、かんばんではなく event テーブルです。項目の作成・更新・状態変更・ノート追加・ワークスペースの作成と設定変更、全部がここに 1 行ずつ追記されます。
CREATE TABLE event ( -- 追記のみ。UPDATE/DELETE しない
id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL REFERENCES workspace(id),
item_id INTEGER REFERENCES item(id),
kind TEXT NOT NULL,
author TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}', -- JSON。moved なら {"from":..,"to":..,"reason":..}
source TEXT NOT NULL CHECK (source IN ('ui','mcp','rest','import')),
created_at TEXT NOT NULL
);
(0001_init.sql L58-67。コメントごと載せたのは、この 1 行目が設計の全部だからです)
列の意味を並べます。
| 列 | 何が入るか |
|---|---|
kind |
item.created / item.updated / item.moved / item.completed / note.added / workspace.created / workspace.updated |
author |
human か ai:<名前>。形式は正規表現で固定(後述) |
payload |
moved なら {"from": "candidate", "to": "doing", "reason": "…"}。updated なら変更したフィールド名だけ |
source |
どの入口から来たか。ui(ブラウザ)/ mcp / rest / import(取り込み) |
payload に本文を入れないのは意図的です。item.updated は「body と priority を変えた」とだけ残し、変更後の本文は残しません。仕事の項目に機密が書かれていても、履歴テーブルにそれが溜まっていかない。テストでも、本文に入れた文字列が payload に含まれないことを固定しています(tests/test_service.py L103)。
author の形は正規表現 1 本で決める
AUTHOR_RE = re.compile(r"^(human|ai:[a-z0-9][a-z0-9._-]{0,39})$")
(src/taskboard/models.py L36)
人間は human の 1 種類、AI は ai: の接頭辞付きです。ブラウザからの書き込みは無条件に human になり、AI 側は名前を自分で名乗れません。この「名乗れない」を実装する場所が次回の主題なので、ここでは形だけ。名前を付けられるので、複数の AI を繋いだときにどの AI が書いたかまで履歴で区別できます。
書き込みは service 層だけが行う
UI・REST・MCP のどこから来ても、書き込みは src/taskboard/service.py の関数を呼びます。そして、書き込む関数は例外なく _log_event を呼びます。状態変更を見てみます(L513-536 の move_item。一部省略)。
def move_item(conn, item_id, status, *, reason="", author, source, _kind="item.moved"):
item = get_item(conn, item_id)
status = m.validate_status(status)
m.validate_author(author)
if status == item.status:
raise Conflict(f"item #{item_id} is already '{status}'")
now = now_utc()
with conn:
conn.execute(
"UPDATE item SET status = ?, completed_at = ?, sort_order = ?, updated_at = ? WHERE id = ?",
(status, now if status == "done" else None, _top_sort_order(conn, item.workspace_id, status), now, item_id),
)
_log_event(conn, item.workspace_id, item_id, _kind, author,
{"from": item.status, "to": status, "reason": reason or ""}, source)
return get_item(conn, item_id)
authorとsourceはキーワード専用引数で必須。呼び出し側が「誰が・どこから」を渡さないと、そもそも呼べませんUPDATEとINSERT INTO eventは同じwith conn:ブロック、つまり同じトランザクションです。片方だけ成功して履歴が欠ける、という状態が起きません- 同じ状態への移動は
Conflictで弾きます。ブラウザなら 409、次回の MCP ではそのまま AI に届く文言になります
ルートハンドラ側(routes/items.py)は、フォームを受け取って service.move_item(..., author="human", source="ui") を呼び、応答を選ぶだけです。ロジックが service 層に集まっているので、次回 MCP を足すときも同じ関数を別の入口から呼ぶだけで済みます。
画面では青と灰のバッジになる
ai:* が書いたものは青、human は灰。カードでも、ノートでも、履歴の行でも、同じマクロで同じ色にしています(_macros.html L2-8)。
項目の詳細。本文は Markdown(表もそのまま)。下の履歴は新しい順で、右端の小さな文字が source(ダミーデータ)
履歴の欄を見ると、item.moved の行には「候補 → 着手『ドラフトを書き始める』」のように前後の状態と理由が、note.added には経路(ui か import か)が出ています。AI が「オシロの波形写真 3 枚が必要」という理由で「待ち(人)」に動かし、人間が「写真は週末に撮る」とノートを返す。このやり取りが、チャットではなく項目に残ります。
編集フォームも htmx で同じ場所に差し込まれます。「編集」ボタンが hx-get でフォームの断片を取り、#detail を丸ごと置き換える。保存すると _detail.html が返ってきて元の表示に戻ります。
編集フォーム。状態の欄が無い。「状態はここでは変えられません(履歴に残すため)」の一文が入っている(ダミーデータ)
🗃️ SQLite を「ちゃんと」使う
SQLite は import sqlite3 で動くので雑に使いがちですが、2 プロセスから同じファイルを触る前提だと、押さえる点が 3 つありました。
接続ごとに PRAGMA を 3 つ
conn = sqlite3.connect(path, timeout=5.0, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
(src/taskboard/db.py L50-54)
SQLite の既定のジャーナル方式(ロールバックジャーナル)では、書き込み中は読み取りが待たされ、読み取り中は書き込みが待たされます。
WAL モードでは、変更をまず別ファイル(taskboard.sqlite3-wal)に追記し、読み取り側は元の DB ファイルを読み続けます。読み取りが書き込みをブロックせず、書き込みも読み取りをブロックしない。あとで「チェックポイント」として本体に書き戻されます。
このアプリでは Web サーバーと(次回の)MCP サーバーが別プロセスで同じファイルを開くので、この性質が要ります。journal_mode = WAL は DB ファイル自体に記録される永続設定ですが、初回作成時に確実に効くよう、接続のたびに発行しています。
foreign_keys = ON— SQLite は既定で外部キー制約を検査しません。DDL にREFERENCESを書いても、この PRAGMA を接続ごとに出さないと飾りです。しかも接続単位の設定なので、毎回出す必要がありますbusy_timeout = 5000— もう片方のプロセスが書いている最中なら、即エラーではなく 5 秒待つcheck_same_thread=False— FastAPI は同期の依存関係(ここでは接続を開くget_conn)をスレッドプールで動かし、async defのルートはイベントループで動かします。接続を開いたスレッドと使うスレッドが違うので、これが無いとsqlite3が例外を投げます。1 つの接続は 1 リクエストの中でしか使わないので、複数スレッドで同時に触ることはありません
バックアップは backup() で
「1 ファイルだからコピーすればいい」は、WAL だと半分正しくありません。書き込み途中の変更が -wal ファイル側にあるので、本体だけコピーすると整合が取れない可能性があります。
Python の sqlite3 にはオンラインバックアップ API があり、動作中の DB から整合した 1 ファイルを作れます(db.py L118-126)。
src = sqlite3.connect(src_path)
dst = sqlite3.connect(dest)
src.backup(dst)
uv run taskboard backup でこれを呼び、data/backups/ に 30 世代まで残します。serve は起動時と 1 時間ごとに「直近のバックアップが 24 時間より古ければ」1 回書きます。外部の cron に頼らず、サーバーの中で済ませています。
マイグレーションは前にだけ進む
schema_version テーブルの値を見て、migrations/000N_*.sql を番号順に、まだ当てていないものだけ適用します(db.py L75-89)。後方には戻しません。Alembic のような道具は入れず、20 行ほどの自前実装です。data/ を消して起動しても、この仕組みでゼロから初期化されます。
🔧 小さいけれど効いたこと
Markdown は生 HTML を通さない
本文とノートは Markdown で、サーバー側で描画します。AI が書いた文字列をそのまま描画する前提なので、markdown-it-py を html=False で使い、生 HTML は必ずエスケープ、javascript: などの URL は markdown-it 既定の validateLink に落とさせています(src/taskboard/render.py L16)。ノートに <script>alert(1)</script> を書いても文字列として表示されることは、テストで固定しています(tests/test_web.py L80-89)。
Windows の Python にはタイムゾーンのデータが無い
DB の時刻は UTC の ISO 8601 文字列で、表示時にローカル時刻へ変換します。ここで zoneinfo.ZoneInfo("Asia/Tokyo") を素直に使うと、Windows では止まります。Windows の Python には IANA のタイムゾーンデータベースが同梱されておらず、tzdata パッケージを別途入れないと ZoneInfoNotFoundError になります。
依存を増やす代わりに、OS のタイムゾーン設定に任せる datetime.astimezone() にしました(render.py L36-43)。サーバーとブラウザが同じ PC なら、これでずれません。別 PC のブラウザから見ると、サーバー側のタイムゾーンで表示されます。
ダークモードは CSS だけ
CSS はフレームワーク無しの 1 ファイル、約 100 行です。ダークモードは prefers-color-scheme で変数を差し替えるだけで、切り替えボタンも JS もありません(static/style.css L8)。
OS をダークモードにするとこうなる。CSS 変数の差し替えだけ(ダミーデータ)
⚠️ できないこと・割り切ったこと
- 削除機能が無い。項目は「保留」へ、ワークスペースはアーカイブへ。物理的に消したければ SQLite を直接触ることになります
- ローカル専用。127.0.0.1 固定・ログイン無し・CSRF 対策無し。LAN や他の PC から使う設計になっていません
- 単一ユーザー。人間は 1 人で、author は
human固定。人間同士を区別したい用途には向きません - ドラッグ&ドロップは無い。htmx だけでは作れず JS が要るので、セレクトで動かします
- 時刻表示はサーバー PC のタイムゾーンになる(前述)
- Windows でしか動かしていません。OS 依存のコードは無いので macOS/Linux でも動くはずですが、確認はしていません
✅ まとめ
- 人と AI が同じボードに書くための条件は 3 つ。AI が書ける口・ページごとの AI 権限・ローカル完結。既製ツールはどれか 1 つが欠けた
- ページ=ワークスペースで分け、
ai_policyの 3 段階(read_write/read_only/hidden)で AI からの見え方を決める。hiddenは拒否ではなく「存在しない」 - htmx なら、セレクトに属性 4 つでかんばんの列が動く。サーバーは
HX-Targetを見て断片か 303 かを選ぶだけ。自前の JS はエラー表示の 1 ブロック - 同名のフォーム値が別の意味で混ざる
statusの衝突は、hx-includeの:not()と隠し要素で回避 - 書き込みは service 層だけ。
authorとsourceを必須にし、UPDATEとeventの追記を同じトランザクションで行う。payloadに本文は入れない - SQLite は接続ごとに WAL・
foreign_keys・busy_timeoutの 3 つ。バックアップはbackup()。Windows の Python に tz データは無い
🔜 次回:この Web アプリに MCP を生やす
作ったボードには、まだ AI の入口がありません。次回は service.py の関数を MCP ツールとして 9 本並べ、Claude Code から「候補を 3 つ足して」と頼むと、かんばんに青いバッジ付きで現れるところまで通します。author をツールの引数ではなくサーバー側で固定する理由、hidden のワークスペースが AI から本当に見えないことの確かめ方、前作で扱った ToolError を「AI が自分で言い直す」ために使う話が中心です。
続きはこちら → 自作 Web アプリに MCP を生やして Claude Code から書き込む|状態を持つ MCP サーバーの作り方
よくある質問(FAQ)
Q: htmx を使うのに Node.js やビルド工程は必要ですか?
A: 不要です。htmx は htmx.min.js 1 ファイルで、このアプリでは src/taskboard/static/ に同梱して <script src="/static/htmx.min.js"> で読んでいます。npm も bundler も使いません。オフラインでも動きます。
Q: 削除機能が無いのはなぜですか?
A: 「誰が何をしたかを全部残す」という方針と衝突するからです。項目は「保留」列へ動かし、ワークスペースは設定からアーカイブして一覧から隠します。event テーブルにはアプリから UPDATE も DELETE も発行しません。どうしても消したい場合は SQLite を直接操作することになります。
Q: SQLite に 2 つのプロセスが同時に書いても壊れませんか?
A: WAL モードと busy_timeout で扱っています。WAL では読み取りと書き込みが互いをブロックせず、書き込み同士が重なった場合は busy_timeout の 5 秒間だけ待ちます。1 人と数個の AI が使う規模なら、この待ちが体感されることはまずありません。同時書き込みが常時発生する規模になったら、サーバー型の DB を検討する段階です。
Q: 他の PC やスマホ(LAN)から使えますか?
A: 使えません。サーバーは 127.0.0.1 にしかバインドせず、--host オプションもありません。ログインも CSRF 対策も無いので、外に出す設計になっていないためです。LAN で使いたい場合はトークン認証を足す必要があり、今回のスコープ外です。
Q: Windows 以外でも動きますか?
A: macOS/Linux では確認していません。OS 固有のコードは無く、依存ライブラリにも Windows 専用のものは無いので動くはずですが、記事の手順は Windows 11 と PowerShell で通したものです。
Q: かんばんのカードをドラッグ&ドロップで動かせますか?
A: 動かせません。ドラッグ&ドロップは htmx の属性だけでは作れず、JavaScript を書く必要があります。今回は「JS を書かない」を優先して <select> で状態を変える形にしました。結果は同じで、履歴にも同じ item.moved が残ります。
関連記事
- 自作 Web アプリに MCP を生やして Claude Code から書き込む|状態を持つ MCP サーバーの作り方:次回。今回のボードに MCP ツールを 9 本載せ、Claude Code から書き込むまで。author の固定・
hiddenの確かめ方・ToolErrorの使い分け - Claude Code で使える MCP ツールの作り方|Python で自作サーバを1本通す:前作。次回はこの記事の
MCPServerとToolErrorを前提に、今回のボードへ書き込む MCP を作ります - 複数の Claude Code を見失わないための盤面 — Kochab Grid:セッションが増えると何が散らかるか。今回の「残項目が散らばる」と同じ出発点
- 自宅MQTTサーバーをDockerで構築|ACLでなりすましを遮断【自宅センサー基盤 #3】:自宅で常駐サービスを立てるもうひとつの型。あちらは Docker、こちらは
uv run1 本 - Claude Code × ESP32|ESP-IDF 6.0 の MCP サーバ活用ガイド:MCP サーバを「使う」側の話