別のモデルに日本語の校閲を任せる、という記事を読んだあと、同じことを自分の環境でも試してみたくなりました。書いた本人のクセは、書いた本人のモデルには見えにくいものです。その発想がとても腑に落ちたからです。
ところが、いざ agy -p を呼ぶスクリプトを書き始めると、細かな疑問が次々に出てきました。文章をパイプで渡せるのか、失敗したときの終了コードはどうなるのか、そもそも 1 回いくらかかるのか。個人開発で 1 本のスクリプトに頼る以上、この三つは先に決めておきたいところです。
最初にお伝えしておきます。私が手元で確かめたのは、シェル側の分岐です。実機の agy には接続しておらず、引数を受け取って終了コードを変える偽の agy で、ラッパーの挙動を一つずつ検証しました。agy 本体の仕様は、公式の Headless mode のページに書かれていることだけを根拠にしています。
公式ドキュメントで確かめた headless の約束事
headless モード(print モード)は、1 つのプロンプトを実行して応答を書き出し、終了します。ラッパーに関係する部分だけを抜き出します。
- プロンプトは
-p、--print、--promptのどれかで渡します。応答は stdout、診断メッセージは stderr に出ます - 認証は保存済みの資格情報を使います。先に対話モードの
agyを一度起動しておく必要があります。未認証のまま端末なしで実行すると、固まらずにauthentication requiredで終了します - モデルは
--model <slug>で指定します。使える slug はagy modelsで一覧できます。headless では別モデルへ黙って切り替わることはなく、未知の slug は非 0 で終了します - 承認が必要な操作は、承認を得られないため soft-deny になります。実行は続き、終了コードは 0 で、stderr に通知が出ます
- 待ち時間の上限は
--print-timeout(既定は 5 分)です
そして、見落としやすい点が一つあります。公式のページには、プレーンテキストを -p へパイプで流し込む方法が書かれていません。--input-format stream-json は標準入力を読みますが、JSON を 1 行ずつ渡す別の仕組みです。そこで、標準入力はラッパー側で読み、プロンプトの文字列に結合して引数として渡すことにしました。
50 行のラッパー
校閲の依頼文は「直す箇所を 3 点(元の文、直し案、理由)で最大 5 件」に絞っています。校閲役に書き直しまで任せると、元の文章の声が失われるためです。
#!/usr/bin/env bash
# 日本語の文章を agy の headless モードで別モデルに校閲させる小さなラッパー
set -euo pipefail
MODEL="${JA_REVIEW_MODEL:-gemini-3.8-flash-medium}"
LIMIT="${JA_REVIEW_TIMEOUT:-3m}"
if [ "$#" -gt 0 ]; then
TEXT="$*"
elif [ ! -t 0 ]; then
TEXT="$(cat)"
else
echo "使い方: ja-review.sh '文章' または cat draft.md | ja-review.sh" >&2
exit 64
fi
if [ -z "${TEXT//[[:space:]]/}" ]; then
echo "入力が空です" >&2
exit 64
fi
# 引数1個の上限(Linux は 128KB)に近づく前に止める
if [ "$(printf '%s' "$TEXT" | wc -c)" -gt 100000 ]; then
echo "入力が長すぎます(100,000 バイト超)。章ごとに分けてください" >&2
exit 65
fi
PROMPT="次の日本語を校閲してください。直す箇所を「元の文 → 直し案 → 理由」の3点で最大5件。事実の追加はしないでください。
${TEXT}"
ERR="$(mktemp)"
trap 'rm -f "$ERR"' EXIT
OUT="$(agy -p "$PROMPT" --model "$MODEL" --print-timeout "$LIMIT" 2>"$ERR")" || {
echo "agy が失敗しました(終了コード $?)" >&2
cat "$ERR" >&2
exit 1
}
# 終了コード 0 でも、承認待ちで道具が soft-deny されると本文が空になりうる
if [ -z "${OUT//[[:space:]]/}" ]; then
echo "応答が空です。stderr:" >&2
cat "$ERR" >&2
exit 3
fi
printf '%s\n' "$OUT"
[ -s "$ERR" ] && { echo "--- stderr ---" >&2; cat "$ERR" >&2; }
exit 0モデルの slug gemini-3.8-flash-medium は、公式ページの --model の例に載っているものです。環境によって使える slug は違うので、先に agy models で確認してください。
使い方は 2 通りです。
./ja-review.sh "校閲したい一文"
cat draft.md | ./ja-review.sh
JA_REVIEW_MODEL=別のslug ./ja-review.sh < draft.md終了コード 0 なのに、応答が空になる
このラッパーでいちばん手間をかけたのは、OUT が空かどうかを調べる判定です。
公式の説明では、承認を得られない操作は soft-deny になり、実行は続いて終了コードは 0 のままです。つまり、agy が成功したように見えても、stdout が空という状態があり得ます。終了コードだけを見るスクリプトは、何も言われないまま空の結果を受け取ることになります。
偽の agy で再現すると、終了コード 0・stdout 空・stderr に通知、という組み合わせになりました。ラッパーはこれを終了コード 3 として扱い、stderr をそのまま見せます。終了コード 0 を信じすぎていたと気づいたのは、この空応答を再現したときでした。
| 状況 | 偽 agy の挙動 | ラッパーの終了コード |
|---|---|---|
| 正常 | 本文を出力して 0 | 0 |
| 入力が空 | agy を呼ばない | 64 |
| 入力が 100,000 バイト超 | agy を呼ばない | 65 |
| agy が非 0 で終了(未知の slug など) | stderr に理由を出して 1 | 1 |
| 終了コード 0 で stdout が空 | stderr に soft-deny の通知 | 3 |
校閲だけなら、ファイルの書き込みもシェル実行も要りません。あえて権限を広げず、空応答を検出して気づく設計にしています。--dangerously-skip-permissions で押し切ると、校閲のはずが文章ファイルを書き換える余地が生まれるからです。
長い原稿で「Argument list too long」
標準入力を読んで引数に結合する方式には、弱点があります。最初の版では、約 15 万バイト(全角 5 万字)の原稿を流すと、偽の agy の起動時点でこう止まりました。
agy が失敗しました(終了コード 126)
./ja-review.sh: line 29: .../bin/agy: Argument list too long約 9 万バイト(全角 3 万字)では通ったので、Linux で引数 1 個の上限が約 128KB であることと合っています。そこで、100,000 バイトを超えたら agy を呼ばずに終了コード 65 で止める確認を足しました。長い原稿は、章ごとに分けて渡してください。
もう一つ、Zenn の記事によると、-p は次のトークンを引数として取るため、-p= の形で渡すほうが安全だそうです。今回のラッパーは、依頼文が必ず日本語の「次の」から始まるので、文章の先頭が - でも引数と取り違えられません。依頼文の頭を固定にしたのは、この点も理由でした。
費用を先に見積もっておく
校閲は何度も回すので、1 回の単価が小さくても、続けるうちに気になってきます。Gemini API の料金ページ(2026-10-15 に確認)では、gemini-3.8-flash の標準料金は次のとおりです。
| 期間 | 入力(100 万トークンあたり) | 出力(思考を含む) |
|---|---|---|
| 現行 | $0.75 | $3.75 |
| 2027 年 1 月 1 日以降 | $1.50 | $7.50 |
ページには、この単価が 2027 年 1 月 1 日に倍になると書かれていました。見積もり用のスクリプトを用意します。
"""校閲1回あたりの API 料金を見積もる。単価は 100万トークンあたりの USD。
2026-10-15 に公式の料金ページで確認した gemini-3.8-flash の標準料金。"""
import sys
PRICES = {
"now": {"input": 0.75, "output": 3.75}, # ~2026-12-31
"2027-01-01": {"input": 1.50, "output": 7.50}, # 2027-01-01 以降
}
def cost(in_tok: int, out_tok: int, period: str) -> float:
p = PRICES[period]
return in_tok / 1_000_000 * p["input"] + out_tok / 1_000_000 * p["output"]
def main() -> None:
in_tok = int(sys.argv[1]) if len(sys.argv) > 1 else 3000
out_tok = int(sys.argv[2]) if len(sys.argv) > 2 else 1200
runs = int(sys.argv[3]) if len(sys.argv) > 3 else 100
for period in PRICES:
one = cost(in_tok, out_tok, period)
out_share = (out_tok * PRICES[period]["output"]) / (one * 1_000_000)
print(f"{period:>10}: 1回 ${one:.4f} / {runs}回 ${one*runs:.2f} (出力が占める割合 {out_share:.0%})")
if __name__ == "__main__":
main()入力 3,000 トークン、出力 1,200 トークン、月 100 回という仮定で実行すると、次の結果でした。トークン数は私の仮定で、実測値ではありません。
now: 1回 $0.0067 / 100回 $0.68 (出力が占める割合 67%)
2027-01-01: 1回 $0.0135 / 100回 $1.35 (出力が占める割合 67%)意外だったのは、入力より出力のほうが費用を支配する点です。入力 3,000 に対して出力は 1,200 と少ないのに、単価が 5 倍なので、費用の 67% は出力が占めます。同じ入力のまま出力を 300 に絞ると、1 回 $0.0034 まで下がります(出力の割合は 33%)。依頼文で「最大 5 件」と件数を決めたのは、文章の質のためだけでなく、費用の面でも効いていました。
ただし、この計算が当てはまるのは API キー経由の従量課金 です。サブスクリプション経由で agy を使う場合に、この単価がそのまま適用されるかは、私は確認できていません。どちらで使うかを決めてから読んでいただければと思います。
つまずきそうな点
- 先に対話モードの
agyを一度起動しておかないと、認証エラーで終了します - 未知の slug は非 0 で終了します。ラッパーはその理由を stderr からそのまま見せます
--disable-slash-commandsは、今回確認した公式ページに記載がありませんでした。使うなら、手元でagy --helpを見てからにしてください- モデルの slug と単価は動きます。この記事の数字は 2026-10-15 時点のものです
次のアクション
終了コード 0 は、成功の証拠にはなりません。出力が空なら、失敗として扱います。 この線引きだけは、ほかのスクリプトにも持ち込むようにしています。
まずは、手元の agy models で slug を一つ選び、短い一文で ja-review.sh を動かしてみていただければ幸いです。そこで空応答が出たら、stderr の通知が最初の手がかりになります。