はじめに
この記事は、ServiceNow Japan の企画する #BuildWithBuildAgentTokyo への参加記事です。
昨年末の ServiceNow アドベントカレンダーで、カクテルを作るアプリ "CocktailBot" を Build Agent を使って実装しました。
やったー!LiquorBotもどき出来たよー!\(^o^)/#ServiceNow #BuildAgent pic.twitter.com/Z4Y2Ep53NH
— キクチユウ (@daifuku085) December 23, 2025
結果的に短期間でアプリを作ることはできたのですが、詳細なプロンプトを渡しても出来上がってきたアプリは不完全な状態であり、デバッグ能力についてもお世辞にも優れているとは言えず、結局は生成されたコードを自分で直す必要があり、それなりに手間がかかりました。
――あれから10か月。Build Agent もかなり進化したらしいので、CocktailBot をもう一度作らせてその進化を体感してみることにしました。
検証に使用した環境
- ServiceNow Australia Patch 3 (PDI環境)
- Build Agent Trial v2.3.3
完成したアプリ
前回の開発内容+前回は実現できなかった以下の機能をスコープに加えました。
- 注文画面に加え、承認画面を追加。承認者が注文内容を承認してからカクテルを作り出す
- ServiceNow → Flaskサーバへオーダーが通ったら
202 Acceptedを返す。その後は処理が完了するまで定期チェックを実施 - UI は Build Agent にお任せしたらどうなるかを確認
注文画面
承認画面
動かした様子
ジン×カンパリ×ヴェルモット×Build Agentでカルディナーレを作りました #ServiceNow #BuildWithBuildAgentTokyo https://t.co/3G2uekEF3I
— キクチユウ (@daifuku085) October 15, 2026
ServiceNowで注文・承認すると、MID Server 経由で Raspberry Pi 上の Flask API が呼ばれ、実際にポンプが動きます。
Flask のログを見ると、POST /drink/cocktail の実行後、定期的に GET /jobs/{job_id} を呼び出して状態を監視していることが確認できます。
10か月前から変わったこと
ここからは、昨年末に Build Agent で開発したときからどんなところが変わっていたか、私の感じたことを書き連ねていきます。
ServiceNow Studio から使えるようになった
前回 Build Agent を試したときは ServiceNow IDE から利用する形で、生成されるアプリも React ベースの SPA が中心でした。そのため、生成されたコードを理解したり修正したりするには、React やフロントエンド開発についてある程度理解が必要という印象を受けました。
今回も注文画面や承認画面には React ベースの UI が生成されていますが、ServiceNow Studio からも Build Agent が利用できるようになったことで、既存の ServiceNow 開発者にとってよりとっつきやすくなったと感じました。
URLや添付ファイルを読み込んでくれるようになった
前回試したときは、必要な仕様をかなり詳細に(英語で10,000字以上!)書き起こしていました。
Australia ではそこまで長文のプロンプトを書き込むことは出来なくなってしまいましたが、プロンプトに埋め込まれた URL の記事を Build Agent 自身が読み、その内容を参考にしながら実装を進めてくれました。
実際に使用したプロンプト
AppName: CocktailBot
Description:
1. 以下のURLの内容を参考にカスタムアプリケーションを作成してください。
https://qiita.com/daifuku085/items/3875d512e117439d1930
2. 申請者用画面と承認者用画面を作成してください。
申請者がカタログ画面からカクテルを注文し、承認者画面で承認されたら
カクテルを作成するようにしてください。
3. Flow / IntegrationHub / Business Rule / Script Include /
RESTMessageV2 / Scripted REST API / UI実装方式など、
現在のServiceNowで最適だと思う構成を選んで実装してください。
4. MID サーバー経由で実行するFlaskアプリケーションは添付の
pump_server.py を使用する想定としてください。
5. HTML/CSS/JavaScriptを利用して、私が驚くようなUIを構成してください。
添付したスクリプト(クリックで展開)
#!/usr/bin/env python3
import math
import os
import queue
import signal
import sys
import threading
import time
import uuid
from collections import OrderedDict
from flask import Flask, Response, request, jsonify
from gpiozero import OutputDevice
# ---------------- 設定 ----------------
PUMP_PIN_MAP = {1: 17, 2: 27, 3: 22, 4: 23} # pump_id -> GPIO(BCM)
PUMP_LABELS = {1: "Pump 1", 2: "Pump 2", 3: "Pump 3", 4: "Pump 4"} # 画面に表示する名前(材料名など)に変更可
MAX_SECONDS_PER_OP = 60.0
MAX_TOTAL_SECONDS = 180.0
MAX_OPS_PER_JOB = 10
MAX_QUEUE_SIZE = 5
JOB_HISTORY = 100
API_KEY = os.environ.get("PUMP_API_KEY", "") # 空なら認証なし
# active_high=False: リレーは LOW で ON
PUMPS = {
pid: OutputDevice(pin, active_high=False, initial_value=False)
for pid, pin in PUMP_PIN_MAP.items()
}
app = Flask(__name__)
job_queue = queue.Queue(maxsize=MAX_QUEUE_SIZE)
jobs = OrderedDict()
jobs_lock = threading.Lock()
stop_event = threading.Event()
abort_current = threading.Event() # 実行中ジョブだけを止める用
# 実行中の状態(画面表示用)。jobs_lock で保護
state = {"job_id": None, "op_index": None, "pump_id": None,
"op_started": None, "op_ends": None}
# ---------------- ポンプ制御 ----------------
def all_off():
for dev in PUMPS.values():
try:
dev.off()
except Exception as e:
print(f"[Pump] off failed: {e}", file=sys.stderr)
def run_pump(pump_id, seconds):
dev = PUMPS[pump_id]
end = time.monotonic() + seconds
try:
dev.on()
while not (stop_event.is_set() or abort_current.is_set()):
remaining = end - time.monotonic()
if remaining <= 0:
break
stop_event.wait(min(0.05, remaining))
finally:
dev.off()
def worker():
while not stop_event.is_set():
try:
job_id = job_queue.get(timeout=0.5)
except queue.Empty:
continue
abort_current.clear()
with jobs_lock:
job = jobs.get(job_id)
if job is None: # /stop で破棄済み
job_queue.task_done()
continue
job["status"] = "running"
job["started_at"] = time.time()
result, err = "done", None
try:
for i, op in enumerate(job["operations"]):
if stop_event.is_set() or abort_current.is_set():
raise RuntimeError("stopped")
now = time.time()
with jobs_lock:
state.update(job_id=job_id, op_index=i,
pump_id=op["pump_id"], op_started=now,
op_ends=now + op["seconds"])
run_pump(op["pump_id"], op["seconds"])
if abort_current.is_set():
raise RuntimeError("stopped")
except Exception as e:
all_off()
result, err = "error", str(e)
finally:
with jobs_lock:
job["status"] = result
job["error"] = err
job["finished_at"] = time.time()
state.update(job_id=None, op_index=None, pump_id=None,
op_started=None, op_ends=None)
job_queue.task_done()
# ---------------- バリデーション/認証 ----------------
def validate_operations(ops):
if not isinstance(ops, list) or not ops:
raise ValueError("'operations' must be a non-empty array")
if len(ops) > MAX_OPS_PER_JOB:
raise ValueError(f"too many operations (max {MAX_OPS_PER_JOB})")
norm, total = [], 0.0
for op in ops:
if not isinstance(op, dict):
raise ValueError(f"invalid operation: {op}")
try:
pump_id = int(op["pump_id"])
seconds = float(op["seconds"])
except (KeyError, TypeError, ValueError):
raise ValueError(f"invalid operation: {op}")
if pump_id not in PUMPS:
raise ValueError(f"unknown pump_id: {pump_id}")
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"seconds must be a positive number: {op}")
if seconds > MAX_SECONDS_PER_OP:
raise ValueError(f"seconds exceeds {MAX_SECONDS_PER_OP}: {op}")
total += seconds
norm.append({"pump_id": pump_id, "seconds": seconds})
if total > MAX_TOTAL_SECONDS:
raise ValueError(f"total seconds exceeds {MAX_TOTAL_SECONDS}")
return norm
def require_key():
if API_KEY and request.headers.get("X-API-Key") != API_KEY:
return jsonify({"status": "error", "message": "unauthorized"}), 401
return None
# ---------------- API ----------------
@app.route("/drink/cocktail", methods=["POST"])
def drink_cocktail():
denied = require_key()
if denied:
return denied
data = request.get_json(silent=True)
if not isinstance(data, dict) or "operations" not in data:
return jsonify({"status": "error",
"message": "Body must be an object with 'operations' array"}), 400
try:
ops = validate_operations(data["operations"])
except ValueError as e:
return jsonify({"status": "error", "message": str(e)}), 400
job_id = uuid.uuid4().hex[:12]
with jobs_lock:
jobs[job_id] = {"job_id": job_id, "status": "queued", "operations": ops,
"error": None, "created_at": time.time()}
while len(jobs) > JOB_HISTORY:
jobs.popitem(last=False)
try:
job_queue.put_nowait(job_id)
except queue.Full:
with jobs_lock:
jobs.pop(job_id, None)
return jsonify({"status": "error", "message": "busy, try again later"}), 429
return jsonify({"status": "accepted", "job_id": job_id,
"queue_position": job_queue.qsize(),
"operations": ops}), 202
@app.route("/jobs/<job_id>", methods=["GET"])
def job_status(job_id):
denied = require_key()
if denied:
return denied
with jobs_lock:
job = jobs.get(job_id)
if not job:
return jsonify({"status": "error", "message": "not found"}), 404
return jsonify(job), 200
@app.route("/stop", methods=["POST"])
def emergency_stop():
"""緊急停止: 実行中のジョブを止め、待機ジョブも破棄する"""
denied = require_key()
if denied:
return denied
# 待機中を先に破棄
while True:
try:
jid = job_queue.get_nowait()
job_queue.task_done()
with jobs_lock:
if jid in jobs:
jobs[jid]["status"] = "error"
jobs[jid]["error"] = "cancelled"
except queue.Empty:
break
abort_current.set()
all_off()
return jsonify({"status": "stopped"}), 200
@app.route("/api/status", methods=["GET"])
def api_status():
denied = require_key()
if denied:
return denied
with jobs_lock:
pumps = [{"pump_id": pid, "label": PUMP_LABELS.get(pid, f"Pump {pid}"),
"pin": PUMP_PIN_MAP[pid], "on": bool(dev.is_active)}
for pid, dev in PUMPS.items()]
current = None
if state["job_id"] and state["job_id"] in jobs:
j = jobs[state["job_id"]]
current = {"job_id": j["job_id"], "operations": j["operations"],
**state}
queued = [j["job_id"] for j in jobs.values() if j["status"] == "queued"]
recent = [dict(j) for j in list(jobs.values())[-10:]][::-1]
return jsonify({"now": time.time(), "pumps": pumps, "current": current,
"queued": queued, "recent": recent,
"auth_required": bool(API_KEY)}), 200
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "queued": job_queue.qsize()}), 200
# ---------------- 画面 ----------------
DASHBOARD_HTML = r"""<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cocktail Pump Status</title>
<style>
:root { --bg:#f5f6f8; --card:#fff; --text:#1c1f24; --sub:#6b7280;
--ok:#16a34a; --run:#2563eb; --err:#dc2626; --line:#e5e7eb; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0f1115; --card:#1a1d23; --text:#e8eaed; --sub:#9aa0a6; --line:#2a2e36; }
}
* { box-sizing: border-box; }
body { margin:0; padding:16px; background:var(--bg); color:var(--text);
font-family:system-ui,-apple-system,"Hiragino Sans","Noto Sans JP",sans-serif; }
.wrap { max-width:720px; margin:0 auto; display:grid; gap:12px; }
header { display:flex; justify-content:space-between; align-items:center; }
h1 { font-size:18px; margin:0; }
.conn { font-size:12px; color:var(--sub); }
.conn::before { content:""; display:inline-block; width:8px; height:8px;
border-radius:50%; background:var(--err); margin-right:6px; }
.conn.ok::before { background:var(--ok); }
.card { background:var(--card); border:1px solid var(--line);
border-radius:12px; padding:14px; }
.card h2 { font-size:13px; margin:0 0 10px; color:var(--sub); font-weight:600; }
.pumps { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; }
.pump { border:1px solid var(--line); border-radius:10px; padding:12px; text-align:center; }
.pump .name { font-size:14px; font-weight:600; }
.pump .pin { font-size:11px; color:var(--sub); }
.pump .st { margin-top:8px; font-weight:700; color:var(--sub); }
.pump.on { border-color:var(--run); }
.pump.on .st { color:var(--run); }
.bar { height:10px; background:var(--line); border-radius:6px; overflow:hidden; margin:8px 0; }
.bar > div { height:100%; width:0; background:var(--run); transition:width .4s linear; }
.muted { color:var(--sub); font-size:13px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
td, th { text-align:left; padding:6px 4px; border-bottom:1px solid var(--line); }
th { color:var(--sub); font-weight:600; font-size:12px; }
.badge { font-size:11px; padding:2px 8px; border-radius:10px; color:#fff; }
.b-done { background:var(--ok); } .b-running { background:var(--run); }
.b-queued { background:var(--sub); } .b-error { background:var(--err); }
button { font:inherit; border:0; border-radius:10px; padding:12px; cursor:pointer; }
#stop { background:var(--err); color:#fff; font-weight:700; width:100%; }
#keybox { display:none; gap:8px; }
#keybox input { flex:1; padding:10px; border-radius:8px; border:1px solid var(--line);
background:var(--bg); color:var(--text); font:inherit; }
#keybox button { background:var(--run); color:#fff; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🍹 Cocktail Pump Status</h1>
<span id="conn" class="conn">接続中…</span>
</header>
<div id="keybox" class="card">
<input id="keyinput" type="password" placeholder="API Key">
<button id="keysave">保存</button>
</div>
<div class="card">
<h2>ポンプ</h2>
<div id="pumps" class="pumps"></div>
</div>
<div class="card">
<h2>実行中のジョブ</h2>
<div id="current" class="muted">待機中(ジョブなし)</div>
<div class="muted" id="queued" style="margin-top:6px"></div>
</div>
<div class="card">
<h2>履歴(直近10件)</h2>
<table>
<thead><tr><th>時刻</th><th>内容</th><th>状態</th></tr></thead>
<tbody id="recent"></tbody>
</table>
</div>
<button id="stop">⛔ 緊急停止</button>
</div>
<script>
const $ = (id) => document.getElementById(id);
const getKey = () => localStorage.getItem("pump_api_key") || "";
const headers = () => getKey() ? {"X-API-Key": getKey()} : {};
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
const fmtTime = (t) => t ? new Date(t * 1000).toLocaleTimeString("ja-JP") : "-";
const fmtOps = (ops, labels) => ops.map(o =>
(labels[o.pump_id] || ("P" + o.pump_id)) + " " + o.seconds + "s").join(" → ");
let labels = {};
function render(d) {
labels = {};
d.pumps.forEach(p => labels[p.pump_id] = p.label);
// pumps
const pe = $("pumps"); pe.replaceChildren();
d.pumps.forEach(p => {
const c = el("div", "pump" + (p.on ? " on" : ""));
c.append(el("div", "name", p.label),
el("div", "pin", "GPIO" + p.pin),
el("div", "st", p.on ? "● 動作中" : "停止"));
pe.append(c);
});
// current
const ce = $("current"); ce.replaceChildren();
if (d.current) {
const c = d.current;
const total = c.operations.length;
const remain = Math.max(0, c.op_ends - d.now);
const pct = Math.min(100, ((d.now - c.op_started) / (c.op_ends - c.op_started)) * 100);
ce.className = "";
ce.append(el("div", "", "Job " + c.job_id + " (" + (c.op_index + 1) + "/" + total + ")"),
el("div", "muted", fmtOps(c.operations, labels)));
const bar = el("div", "bar"); const inner = el("div"); inner.style.width = pct + "%";
bar.append(inner);
ce.append(bar, el("div", "muted",
(labels[c.pump_id] || ("P" + c.pump_id)) + " 残り " + remain.toFixed(1) + " 秒"));
} else {
ce.className = "muted"; ce.textContent = "待機中(ジョブなし)";
}
$("queued").textContent = d.queued.length ? ("待機: " + d.queued.length + " 件") : "";
// recent
const re = $("recent"); re.replaceChildren();
d.recent.forEach(j => {
const tr = el("tr");
const st = el("td"); st.append(el("span", "badge b-" + j.status, j.status));
if (j.error) st.append(el("span", "muted", " " + j.error));
tr.append(el("td", "", fmtTime(j.created_at)),
el("td", "", fmtOps(j.operations, labels)), st);
re.append(tr);
});
}
async function poll() {
try {
const r = await fetch("/api/status", {headers: headers(), cache: "no-store"});
if (r.status === 401) {
$("keybox").style.display = "flex";
$("conn").textContent = "認証が必要"; $("conn").className = "conn";
} else {
$("keybox").style.display = "none";
render(await r.json());
$("conn").textContent = "接続中 " + new Date().toLocaleTimeString("ja-JP");
$("conn").className = "conn ok";
}
} catch (e) {
$("conn").textContent = "接続できません"; $("conn").className = "conn";
}
}
$("keysave").onclick = () => {
localStorage.setItem("pump_api_key", $("keyinput").value.trim()); poll();
};
$("stop").onclick = async () => {
if (!confirm("全ポンプを停止し、待機中のジョブも破棄します。よろしいですか?")) return;
await fetch("/stop", {method: "POST", headers: headers()});
poll();
};
poll();
setInterval(poll, 1000);
</script>
</body>
</html>
"""
@app.route("/", methods=["GET"])
def dashboard():
# 画面自体は認証なし。データ取得(/api/status)と停止(/stop)にキーが必要
return Response(DASHBOARD_HTML, mimetype="text/html")
# ---------------- 起動/終了 ----------------
def shutdown(*_):
stop_event.set()
all_off()
for dev in PUMPS.values():
dev.close()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
threading.Thread(target=worker, daemon=True).start()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)
プロンプトの実行結果(抜粋)
添付ファイル pump_server.py については、最初貼り付けたときは期待したものとは異なる構成(HTTP 20x番台を受信したら成功とし、その後はMIDサーバー側からコールバックAPIを叩いてもらう)になりましたが、再度アップロードすることでソースコードの内容を理解し、ServiceNow 側の実装・修正をしてくれました。
作れる ServiceNow のリソースが増えた
前回 Build Agent に作らせたときは REST Message を作ってくれず、最終的には人間が補う必要がありましたが、今回は REST Message に加えて Scripted REST API や System Property なども Build Agent が作成してくれました。
なお、Australia リリースでは Flow も扱えるようになった1とのことだったので、最初のプロンプトに
Flow / IntegrationHub / Business Rule / Script Include / RESTMessageV2 / Scripted REST API / UI実装方式など、現在のServiceNowで最適だと思う構成を選んで実装してください。
という文言を入れたのですが、結果として Build Agent は承認・API 実行処理に Flow ではなく Business Rule + Script Include による実装を選択しており、興味深い結果となりました。
バグ改修能力が向上した
今回一番驚いたポイントになります。
前回はエラーの原因調査や修復を指示しても解決できず、いたずらにプロンプトの残り回数を浪費してしまいましたが、今回は期待どおりに解決してくれる場面が増えました。Build Agent君、前回はポンコツとか言ってゴメンネ
実際 Build Agentの作業を眺めていると、生成済みファイルを読む→Fluentのドキュメントを調べる→Run Queryで実インスタンスのレコードを確認する→修正→Build&Install→再確認 という動きを頻繁に行っていました。
ただし、「期待したとおりに動きません、直してください」程度のざっくりとした指示では問題を解決できない場面が多かったので、出力されたエラーメッセージを貼り付けたり、被疑箇所の当たりをつける等のひと手間は惜しまないようにしましょう。
今回も、最後の非同期ポーリング処理については Build Agent が生成した実装では正常に動作せず、最終的には人間側で修正する必要がありました。
それでも10か月前と比べると、デバッグを任せられる範囲はかなり広がったと感じました。
その他、気になった点
今回使用した Mac + Chrome 環境では、Enter キー押下でそのままプロンプトが送信されました。
日本語入力の漢字変換を確定するために Enter キーを押した場合や、画面の反応がなく「あれ?」と思って再度 Enter キーを押した場合も、1 interaction としてカウントされます。
PDI 環境の Build Agent Trial には 25 interactions / 30日 の制限がありますが、今回は誤送信で5回消費してしまいました。
無料枠で試す場合は、入力途中で Enter キーを押さないよう注意が必要です。
※追記(2026/10/22)
コメントで教えていただいたのですが、プロンプトが誤送信される問題については Australia Patch 5 以降で解決済みとのことです。
まとめ
10か月前と比べて、Build Agent は作れるリソースの種類だけでなく、URLや添付ファイルの参照、実インスタンスを確認しながらのデバッグなど、任せられる範囲がかなり広がっていました。
今回も最後の非同期処理は人間側で修正しましたが、最終的には 注文 → 承認 → MID Server → Raspberry Pi → 実機ポンプ動作 まで到達できました。
だいぶ実用に耐えうるレベルに育ってきたなという印象ですので、今後の更なる発展に期待大です。
World Forum Tokyo 2026 のご案内
ServiceNow の最新技術や導入事例を紹介するイベント 「World Forum Tokyo 2026」 が、2026年10月27日・28日にグランドプリンスホテル新高輪で開催されます。
今年も AI を大きなテーマとして、AIエージェントの構築・テストや、今回の記事でも扱った バイブコーディングを実際に体験できるセッション なども予定されています。
ServiceNow Build Agent を含め、ServiceNow の AI が今後どのように進化していくのか気になる方は、ぜひチェックしてみてください。
ServiceNow World Forum Tokyo 2026 の詳細・登録はこちら





