After reading a write-up about letting a second model proofread Japanese text, I wanted the same thing in my own setup. The idea landed because the habits of a writer are hardest to see for the writer's own model.
Then I started writing the script that calls agy -p, and small questions piled up. Can I pipe text in? What does failure look like as an exit code? And what does one run cost? As an indie developer who will lean on a single script, I wanted those three settled first.
One honest note up front: what I verified myself is the shell side. I did not connect to a real agy. I used a stub agy that echoes its arguments and changes its exit code on demand, and tested the wrapper's branches one by one. Everything about agy itself comes only from the official Headless mode page.
What the docs promise about headless mode
Headless (print) mode runs one prompt, writes the response, and exits. These are the parts that matter for a wrapper:
- Pass the prompt with
-p,--print, or--prompt. The response goes to stdout; diagnostics go to stderr. - It uses cached credentials, so run an interactive
agysession once first. Without a terminal and without auth, it exits withauthentication requiredinstead of hanging. - Choose a model with
--model <slug>; list slugs withagy models. Headless mode does not silently fall back, and an unknown slug exits non-zero. - Tools that need approval are soft-denied: the run continues, the exit code stays 0, and stderr gets a notice.
--print-timeoutcaps the wait (default 5 minutes).
One thing is easy to miss: the page does not describe piping plain text into -p. --input-format stream-json does read stdin, but it is a different mechanism that takes one JSON object per line. So the wrapper reads stdin itself and passes the combined prompt as an argument.
The 50-line wrapper
The instruction asks for at most five findings, each as original sentence, suggested fix, and reason. If you let the reviewer rewrite everything, the original voice disappears.
#!/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 0The slug gemini-3.8-flash-medium comes from the --model example on the official page. Slugs differ by environment, so check agy models first.
./ja-review.sh "a sentence to proofread"
cat draft.md | ./ja-review.sh
JA_REVIEW_MODEL=another-slug ./ja-review.sh < draft.mdExit code 0, empty answer
The check I spent the most time on is the one that tests whether OUT is empty.
According to the docs, an operation that cannot get approval is soft-denied and the run continues with exit code 0. So agy can look successful while stdout is empty. A script that only checks the exit code will quietly accept nothing.
Reproducing it with the stub gave exactly that combination: exit 0, empty stdout, a notice on stderr. The wrapper turns it into exit code 3 and shows stderr as is. I realized I had been trusting exit 0 too much when I saw that empty answer for the first time.
| Situation | Stub behavior | Wrapper exit code |
|---|---|---|
| Normal | prints text, exits 0 | 0 |
| Empty input | agy is not called | 64 |
| Input over 100,000 bytes | agy is not called | 65 |
| agy exits non-zero (e.g. unknown slug) | reason on stderr, exit 1 | 1 |
| Exit 0 but empty stdout | soft-deny notice on stderr | 3 |
A proofreading job needs neither file writes nor shell commands, so I deliberately did not widen permissions. Using --dangerously-skip-permissions to force it through would leave room for a proofread to turn into an edit of your draft.
"Argument list too long" on big drafts
Reading stdin and joining it into an argument has a weak spot. In my first version, a roughly 150,000-byte draft (about 50,000 Japanese characters) stopped right at launch:
agy failed (exit code 126)
./ja-review.sh: line 29: .../bin/agy: Argument list too longA 90,000-byte draft went through, which matches the Linux limit of about 128 KB for a single argument. So the wrapper now refuses anything over 100,000 bytes with exit code 65, before calling agy. Split long drafts by chapter.
A Zenn write-up also reports that -p consumes the next token, so the -p= form is safer. My prompt always starts with a fixed Japanese phrase, so a draft that begins with - cannot be mistaken for a flag. Fixing the head of the prompt was partly for that reason.
Estimate the cost first
Proofreading runs often, so even a tiny unit price starts to matter. On the Gemini API pricing page (checked on 2026-10-15), standard pricing for gemini-3.8-flash is:
| Period | Input (per 1M tokens) | Output (incl. thinking) |
|---|---|---|
| Current | $0.75 | $3.75 |
| From January 1, 2027 | $1.50 | $7.50 |
The page states that these prices double on January 1, 2027. Here is a small estimator:
"""校閲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()With 3,000 input tokens, 1,200 output tokens, and 100 runs a month (my assumptions, not measurements), the result was:
now: 1回 $0.0067 / 100回 $0.68 (出力が占める割合 67%)
2027-01-01: 1回 $0.0135 / 100回 $1.35 (出力が占める割合 67%)The surprise: output, not input, drives the bill. Output is the smaller side (1,200 against 3,000), yet at five times the unit price it makes up 67% of the cost. Cutting output to 300 tokens drops one run to $0.0034. Capping the findings at five helped quality, and it helped the bill too.
This only applies to pay-as-you-go API keys. I could not confirm whether these prices apply when you use agy through a subscription, so decide which route you use before relying on the numbers.
Gotchas
- Run an interactive
agyonce first, or you will get an authentication error. - An unknown slug exits non-zero; the wrapper surfaces the reason from stderr.
--disable-slash-commandsis not mentioned on the official page I checked. Runagy --helpbefore using it.- Slugs and prices move. The numbers here are as of 2026-10-15.
Next step
Exit code 0 is not proof of success; an empty answer counts as a failure. I plan to carry that rule into my other scripts as well.
Pick one slug from your own agy models, run ja-review.sh on a single sentence, and if an empty answer shows up, the stderr notice is where I would start.