AI
WhatsApp AI Agent Indonesia: Bot Pesanan UMKM dengan Agents SDK
Oktober 202612 menit baca

Boleh, selama AI-nya mendukung layanan pelanggan bisnis itu sendiri. Ketentuan WhatsApp Business Platform dari Meta melarang AI Provider memakai platform jika AI adalah fungsi utamanya, misalnya asisten umum ala ChatGPT, tetapi agent toko yang menjawab soal produk dan pesanannya sendiri termasuk penggunaan sekunder atau pendukung. Periksa ketentuan terbaru sebelum launch karena sudah beberapa kali direvisi.
Sejak 1 Juli 2025 Meta menagih per template message yang terkirim. Balasan non-template dan utility template yang dikirim di dalam customer service window 24 jam gratis, jadi agent yang hanya membalas pelanggan yang baru saja chat tidak membayar biaya pesan. Marketing template selalu ditagih, dan angka rupiah sebenarnya ada di rate card per negara pada halaman harga Meta.
Gunakan session OpenAI Agents SDK yang dikunci dengan WhatsApp id pelanggan, misalnya SQLAlchemySession di Postgres atau RedisSession. Setiap worker yang memproses pesan dari nomor itu membaca dan menambah riwayat yang sama. Tambahkan lock per nomor agar beberapa bubble pesan yang cepat diproses berurutan, bukan paralel.
Beri triage agent sebuah handoff Agents SDK dengan callback on_handoff. Saat model memanggilnya, callback menyalakan flag mode manusia di database dan memberi tahu pemilik toko, lalu worker melewati agent selama flag itu aktif. Admin kemudian membalas dari inbox aplikasi Anda lewat nomor Cloud API yang sama, di dalam window 24 jam.
Meta melakukan retry pada webhook yang tidak menerima HTTP 200, hingga tujuh hari, dan retry itu bisa mengirim pesan yang sama lebih dari sekali. Jika endpoint Anda menunggu model AI sebelum membalas, model yang lambat memicu retry dan jawaban ganda. Balas 200 segera, proses pesan di worker, dan lakukan dedupe berdasarkan message id WhatsApp.

Ringkasan Utama
WhatsApp AI agent untuk UMKM Indonesia sebaiknya hanya membalas di dalam customer service window 24 jam, saat Meta tidak menagih balasan non-template. Ambil stok dan harga dari tool, bukan dari prompt, simpan satu session Agents SDK per nomor telepon, dan serahkan pertanyaan pembayaran, ongkir, serta komplain ke manusia lewat handoff.
Bayangkan toko biji kopi di Bandung yang berjualan lewat WhatsApp. Jam sembilan malam, pemiliknya punya empat puluh chat belum dibaca, dan sebagian besar isinya empat pertanyaan yang sama: Gayo masih ready, berapa harga yang 500 gram, bisa transfer, dan kapan dikirim. Semuanya menunggu sampai pagi, dan sebagian pembeli akhirnya belanja di toko yang membalas lebih dulu.
Panduan ini membangun WhatsApp AI agent untuk UMKM Indonesia seperti itu di atas WhatsApp Cloud API dan OpenAI Agents SDK untuk Python: webhook yang tahan terhadap retry dari Meta, tool yang membaca stok dan harga sungguhan, satu session per nomor telepon, prompt dalam Bahasa Indonesia, dan handoff ke admin manusia. Setup dasar Cloud API sudah dibahas di artikel integrasi WhatsApp Business API di situs ini; artikel ini membahas agent di atasnya, dan setiap klaim soal harga dan platform diambil dari dokumentasi resmi Meta dan OpenAI.
Ya, untuk kegunaan ini. Ketentuan Meta untuk WhatsApp Business Platform melarang AI Provider memakai platform untuk mendistribusikan teknologi AI ketika teknologi itu adalah fungsi utama layanannya, dan halaman harga AI provider dari Meta mencatat kebijakan itu berlaku sejak 15 Januari 2026. Ketentuan tersebut membedakan fungsi utama dari fungsi sekunder atau pendukung. Toko yang agent-nya menjawab pertanyaan tentang produknya sendiri dan menerima pesanannya sendiri adalah bisnis yang memakai AI dalam alur kerja layanan pelanggan, yaitu kasus pendukung. Nomor yang menawarkan diri menjawab apa saja ala ChatGPT adalah kasus yang memang ingin dihentikan aturan itu.
Perbedaan ini bukan sekadar urusan legal; ia ikut menentukan desain. Instruksi agent membatasinya pada katalog dan pesanan toko, permintaan di luar topik ditolak dengan sopan, dan tool yang dimilikinya hanya bisa menyentuh data toko itu. Agent yang sempit lebih mudah dipertahankan di bawah ketentuan, lebih murah dijalankan, dan lebih kecil kemungkinannya mengucapkan sesuatu yang membuat pemilik toko harus minta maaf.
Periksa ketentuan terbaru sebelum launch, bukan artikel ini. Halaman Meta Terms for WhatsApp Business Platform terakhir diperbarui 23 September 2026, dan aturan AI provider sudah berubah beberapa kali sejak akhir 2025. Kalau produk Anda adalah asisten yang dijual ulang ke banyak toko, bukan layanan pelanggan milik satu toko, baca bagian AI Providers bersama konsultan hukum, karena skenario itulah yang disasar.
Sejak 1 Juli 2025 Meta menagih WhatsApp Business Platform per pesan, dan biaya hanya muncul ketika template message terkirim. Setiap pesan dari pelanggan membuka atau memperpanjang customer service window 24 jam, dan di dalamnya balasan non-template Anda tidak dikenai biaya. Satu aturan itu menentukan sebagian besar arsitektur: agent yang hanya membalas pesan masuk, dengan cepat, berjalan tanpa biaya pesan.
| Yang Anda kirim | Di dalam window 24 jam | Di luar window | Konsekuensi desain |
|---|---|---|---|
| Balasan free-form dari agent | Gratis | Sama sekali tidak boleh; hanya template yang bisa dikirim | Cek window sebelum mengirim, bukan sebelum menjalankan model |
| Utility template, misalnya konfirmasi pesanan | Gratis | Ditagih per pesan yang terkirim | Kirim update pesanan dan pengiriman selagi pelanggan masih chat |
| Marketing template, misalnya blast promo | Ditagih | Ditagih | Jangan pernah beri agent tool untuk mengirimnya |
| Balasan apa pun untuk chat yang dibuka dari iklan Click-to-WhatsApp atau tombol Facebook Page | Gratis, dan jika Anda membalas dalam 24 jam, terbuka free entry point window 72 jam | Aturan normal berlaku lagi | Chat dari iklan bisa menampung percakapan jualan yang lebih panjang dan santai |
Authentication template juga ditagih, tetapi agent toko tidak punya alasan untuk mengirimnya. Aturan praktisnya singkat: agent hanya mengirim teks free-form, template hanya dikirim lewat jalur kode yang sengaja dikonfigurasi orang, dan rate card per negara di halaman harga Meta adalah satu-satunya tempat mengambil angka rupiah yang sebenarnya. Tarif bisa berubah, jadi perkiraan biaya sebaiknya ada di spreadsheet yang menautkan halaman itu, bukan ditulis mati di artikel ini.
Meta menandatangani setiap POST webhook dengan HMAC-SHA256 dari raw body memakai app secret Anda, lalu mengirimnya di header X-Hub-Signature-256. Jika endpoint Anda membalas selain 200, Meta melakukan retry dengan frekuensi menurun hingga tujuh hari, dan dokumentasinya mengingatkan bahwa retry bisa menghasilkan notifikasi ganda. Kedua fakta itu mengarah ke bentuk yang sama: webhook sama sekali tidak mengerjakan AI.
# webhook.py - FastAPI. The only job here: prove it is Meta, dedupe, enqueue, return 200.
import hashlib, hmac, json, os
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
APP_SECRET = os.environ["META_APP_SECRET"].encode()
VERIFY_TOKEN = os.environ["WA_VERIFY_TOKEN"]
@app.get("/webhook/whatsapp")
async def verify(request: Request):
q = request.query_params
if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
# Raw challenge as the body - not JSON, not quoted.
return Response(content=q.get("hub.challenge"), media_type="text/plain")
raise HTTPException(status_code=403)
@app.post("/webhook/whatsapp")
async def receive(request: Request):
raw = await request.body() # sign the RAW bytes, never re-serialised JSON
sent = request.headers.get("X-Hub-Signature-256", "").removeprefix("sha256=")
expected = hmac.new(APP_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected):
raise HTTPException(status_code=401)
payload = json.loads(raw)
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for msg in value.get("messages", []):
# Meta retries non-200 deliveries for up to 7 days, so the same
# message id WILL arrive twice. SET NX makes the second one a no-op.
if await redis.set(f"wa:seen:{msg['id']}", 1, nx=True, ex=8 * 86400):
await queue.enqueue("handle_inbound", msg, value["metadata"])
# value["statuses"] (sent/delivered/read) goes to a separate, cheap handler.
# Return before any LLM call. A slow model must never turn into a retry storm.
return Response(status_code=200)Pemanggilan model terjadi di worker. Kalau endpoint menunggu model dan model sedang lambat, Meta melihat timeout, melakukan retry, dan pelanggan menerima jawaban dua kali; SET NX pada message id itulah yang membuat kiriman kedua tidak berdampak apa-apa. Array statuses, yang melaporkan sent, delivered, dan read, datang lewat webhook yang sama dan berguna untuk dashboard admin, tetapi tidak boleh memicu agent.
Kesalahan termahal agent toko adalah menyebut harga yang salah, karena pelanggan akan men-screenshot-nya. Jadi harga dan stok tidak pernah ditaruh di prompt. Agent mendapat dua tool: satu untuk mencari produk di tabel milik toko dan mengembalikan harga serta stok saat itu juga, dan satu untuk membuat pesanan sebagai draft. Run context membawa shop id dan WhatsApp id pelanggan, sehingga model tidak bisa bertanya soal toko lain atau membuat pesanan untuk nomor lain.
# tools.py - the model names a product; the database answers. Prices never come from the prompt.
from dataclasses import dataclass
from agents import RunContextWrapper, function_tool
@dataclass
class ShopContext:
shop_id: int
wa_id: str # the customer's WhatsApp id, e.g. "6281234567890"
customer_name: str
@function_tool
async def cek_stok_harga(ctx: RunContextWrapper[ShopContext], nama_produk: str) -> str:
"""Cari produk di katalog toko dan kembalikan harga serta stok saat ini.
Args:
nama_produk: Nama atau kata kunci produk yang ditanyakan pelanggan.
"""
rows = await db.fetch(
"""SELECT sku, nama, varian, harga_idr, stok
FROM produk
WHERE shop_id = $1 AND aktif AND nama ILIKE '%' || $2 || '%'
LIMIT 5""",
ctx.context.shop_id, nama_produk,
)
if not rows:
return "TIDAK_DITEMUKAN" # an explicit token the prompt tells the agent how to handle
rupiah = lambda n: f"Rp{n:,}".replace(",", ".") # Rp125.000, the way customers write it
return "\n".join(
f"{r['sku']} | {r['nama']} {r['varian'] or ''} | {rupiah(r['harga_idr'])} | stok {r['stok']}"
for r in rows
)
@function_tool
async def buat_draft_pesanan(
ctx: RunContextWrapper[ShopContext], sku: str, jumlah: int, alamat: str
) -> str:
"""Buat DRAFT pesanan. Pesanan baru diproses setelah admin mengonfirmasi pembayaran.
Args:
sku: SKU persis seperti yang dikembalikan cek_stok_harga.
jumlah: Jumlah barang, minimal 1.
alamat: Alamat pengiriman lengkap dari pelanggan.
"""
if jumlah < 1 or jumlah > 50:
return "JUMLAH_TIDAK_VALID"
# Status 'draft' on purpose: the agent can take an order, it cannot accept money.
draft_id = await db.fetchval(
"""INSERT INTO pesanan (shop_id, wa_id, sku, jumlah, alamat, status)
VALUES ($1, $2, $3, $4, $5, 'draft') RETURNING id""",
ctx.context.shop_id, ctx.context.wa_id, sku, jumlah, alamat,
)
return f"DRAFT-{draft_id}"Ada dua detail yang lebih penting daripada kelihatannya. Tool mengembalikan token eksplisit seperti TIDAK_DITEMUKAN dan JUMLAH_TIDAK_VALID, dan prompt menjelaskan apa yang harus dilakukan untuk masing-masing, sehingga produk yang tidak ada menjadi kalimat yang jelas, bukan tebakan improvisasi. Lalu pesanan dibuat dengan status draft: agent boleh mengumpulkan produk, jumlah, dan alamat, tetapi mengubah draft menjadi pesanan lunas hanya terjadi setelah orang melihat bukti transfer. Docstring ditulis dalam Bahasa Indonesia karena ia menjadi deskripsi tool yang dibaca model, dalam bahasa yang sama dengan percakapan.
Agents SDK menyimpan riwayat percakapan di objek session yang dikunci dengan session id, dan backend bawaannya mencakup SQLiteSession, RedisSession, dan SQLAlchemySession. Untuk agent WhatsApp, kunci yang paling wajar adalah WhatsApp id pelanggan, sehingga setiap nomor punya memorinya sendiri, dan worker mana pun yang mengambil pesan berikutnya membaca riwayat yang sama dari Postgres.
# worker.py - one queue job per inbound message
# pip install "openai-agents[sqlalchemy]" asyncpg
import os
from datetime import datetime, timedelta, timezone
from agents import Runner
from agents.extensions.memory import SQLAlchemySession
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(os.environ["DATABASE_URL"]) # postgresql+asyncpg://...
WINDOW = timedelta(hours=24)
async def handle_inbound(msg: dict, metadata: dict):
wa_id = msg["from"]
if msg["type"] != "text":
return await send_text(wa_id, "Maaf Kak, untuk saat ini kami baru bisa membaca pesan teks ya.")
# Customers send three bubbles in a row. A per-number lock keeps those turns
# in order instead of three agents racing on the same history.
async with redis.lock(f"wa:lock:{wa_id}", timeout=120):
if await is_with_human(wa_id):
return # an admin owns this chat; the bot stays silent
await mark_read_with_typing(msg["id"])
session = SQLAlchemySession(
f"wa:{wa_id}", # one session per phone number
engine=engine,
create_tables=True,
ensure_ascii=False, # keep "Rp", emoji and Indonesian text readable in the DB
)
ctx = ShopContext(shop_id=SHOP_ID, wa_id=wa_id, customer_name=await name_for(wa_id))
result = await Runner.run(triage_agent, msg["text"]["body"], session=session, context=ctx)
# A backlog can outlive the free window. Check before sending, not before running.
sent_at = datetime.fromtimestamp(int(msg["timestamp"]), tz=timezone.utc)
if datetime.now(timezone.utc) - sent_at > WINDOW:
return await flag_for_template_followup(wa_id, result.final_output)
await send_text(wa_id, result.final_output)
async def mark_read_with_typing(message_id: str):
# One call: blue ticks plus a typing indicator (dismissed on reply or after 25 s).
await graph_post(f"/{PHONE_NUMBER_ID}/messages", {
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
"typing_indicator": {"type": "text"},
})Lock adalah bagian yang sering dilewatkan. Pelanggan Indonesia sering mengetik satu pertanyaan dalam tiga bubble pendek, yang datang sebagai tiga webhook berjarak satu detik; tanpa lock per nomor, tiga worker menjalankan tiga agent di atas riwayat yang sama dan membalas dengan urutan acak. Pengecekan window sengaja diletakkan setelah run: kalau backlog antrean sampai menunda job lebih dari 24 jam, balasannya ditahan agar dikirim orang sebagai template, alih-alih gagal di API. Panggilan read sekaligus menampilkan typing indicator, yang hilang saat Anda membalas atau setelah 25 detik.
Berikan ensure_ascii=False ke SQLAlchemySession. Opsi ini hanya mengubah cara riwayat diserialisasi di database, tetapi membuat staf yang membaca percakapan tersimpan melihat Kak, Rp125.000, dan emoji pelanggan, bukan unicode yang di-escape, dan itu penting saat pertama kali harus menelusuri sebuah komplain.
Instruksi ditulis dalam Bahasa Indonesia, dengan alasan yang sama seperti deskripsi tool: model meniru bahasa dan gaya yang diberikan kepadanya. Prompt berbahasa Inggris yang disuruh membalas dalam Bahasa Indonesia cenderung menghasilkan kalimat kaku seperti terjemahan, dan pelanggan menyadarinya. Ada empat hal yang layak ditulis secara eksplisit.
INSTRUKSI_TOKO = """
Kamu adalah admin chat Toko Kopi Sari Rasa di Bandung. Kamu HANYA membantu soal
produk toko ini: stok, harga, varian, cara pesan, dan status pesanan.
Gaya bahasa:
- Panggil pelanggan "Kak". Ramah, singkat, maksimal 3 kalimat per balasan.
- Pelanggan sering menulis singkat ("brp kak", "ready?", "bs cod?"). Pahami, jangan koreksi.
- Ikuti bahasa pelanggan: kalau mereka menulis dalam bahasa Inggris, balas dalam bahasa Inggris.
Aturan keras:
- Harga dan stok WAJIB dari tool cek_stok_harga. Jangan pernah menebak angka.
- Kalau tool mengembalikan TIDAK_DITEMUKAN, katakan produknya belum tersedia.
- Ongkir, diskon, dan konfirmasi pembayaran BUKAN wewenangmu: serahkan ke admin.
- Pertanyaan di luar toko (PR sekolah, politik, resep umum): tolak dengan sopan,
arahkan kembali ke produk.
"""Prompt juga membawa aturan cakupan dari bagian pertama: pertanyaan di luar urusan toko ditolak dengan sopan dan diarahkan kembali ke produk. Satu paragraf itu yang menjaga agent tetap di sisi pendukung dalam ketentuan Meta, dan layak diuji dengan beberapa pesan yang sengaja melenceng sebelum launch.
Handoff di Agents SDK ditampilkan ke model sebagai tool, dan memanggilnya memindahkan percakapan ke agent lain. Dengan handoff() Anda bisa mengganti nama tool itu, memberi deskripsi yang akan diikuti model, mewajibkan input terstruktur, dan menjalankan callback on_handoff saat terpicu. Callback itulah tempat handover yang sesungguhnya terjadi, di dalam kode.
# agents_setup.py
from pydantic import BaseModel
from agents import Agent, RunContextWrapper, handoff
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
class AlasanHandover(BaseModel):
alasan: str # "minta ongkir", "komplain barang rusak", "bukti transfer"
async def serahkan_ke_admin(ctx: RunContextWrapper[ShopContext], data: AlasanHandover):
# Side effects live in code, not in the model's goodwill:
# flip the chat to human mode and ping the shop owner's phone.
await set_with_human(ctx.context.wa_id, reason=data.alasan)
await notify_owner(f"Chat {ctx.context.customer_name} perlu admin: {data.alasan}")
admin_manusia = Agent[ShopContext](
name="Admin manusia",
instructions=(
"Beri tahu pelanggan bahwa admin akan membalas sebentar lagi, "
"jam kerja 08.00-20.00 WIB. Satu kalimat. Jangan menjawab pertanyaannya sendiri."
),
)
triage_agent = Agent[ShopContext](
name="CS Toko",
instructions=f"{RECOMMENDED_PROMPT_PREFIX}\n{INSTRUKSI_TOKO}",
tools=[cek_stok_harga, buat_draft_pesanan],
handoffs=[
handoff(
admin_manusia,
tool_name_override="serahkan_ke_admin",
tool_description_override=(
"Pakai untuk ongkir, diskon, komplain, bukti transfer, "
"atau jika pelanggan minta bicara dengan orang."
),
on_handoff=serahkan_ke_admin,
input_type=AlasanHandover,
)
],
)Ketika triage agent memutuskan sebuah pertanyaan butuh manusia, ia memanggil serahkan_ke_admin beserta alasannya. Callback menandai chat sebagai milik manusia dan memberi tahu pemilik toko, lalu admin agent mengirim satu kalimat bahwa admin akan membalas di jam kerja. Sejak saat itu pengecekan is_with_human di worker melewati agent sepenuhnya, sehingga bot tidak bisa menyela admin.
Balasan admin sebaiknya dikirim lewat nomor Cloud API yang sama dari inbox sederhana yang disediakan aplikasi Anda, dan aturan harganya sama: balasan free-form gratis dan hanya boleh selama window 24 jam pelanggan masih terbuka. result.last_agent di SDK memberi tahu agent mana yang menyelesaikan sebuah run, berguna untuk logging, tetapi mode manusia adalah flag di database, bukan agent, supaya tetap bertahan saat restart. Lepaskan mode itu lewat aksi admin yang eksplisit atau setelah periode sepi yang tetap, dan catat keduanya di log.
Sebagian besar kegagalan agent WhatsApp bukan kegagalan model. Itu masalah sistem terdistribusi biasa yang muncul di chat, tempat pelanggan langsung melihatnya.
| Gejala yang dilihat pelanggan | Penyebab umum | Perbaikan |
|---|---|---|
| Jawaban yang sama datang dua kali | Meta melakukan retry pada webhook yang lambat membalas 200 | Balas 200 sebelum memanggil model, dan dedupe berdasarkan message id |
| Balasan datang tidak berurutan atau saling bertentangan | Tiga bubble pesan diproses bersamaan | Lock per nomor yang membungkus seluruh run |
| Harga kemarin disebut hari ini | Model memakai ulang hasil tool lama dari riwayat session | Instruksikan agar tool harga dipanggil setiap kali harga disebut, dan jaga riwayat tetap pendek |
| Bot terus menjawab setelah admin masuk | Mode manusia hanya ada di memori atau di prompt, bukan di database | Cek flag yang tersimpan sebelum setiap run |
| Tagihan pesan tak terduga di akhir bulan | Otomasi mengirim template di luar window, misalnya follow-up ke chat yang sepi | Jangan beri agent tool template, dan review setiap jalur kode yang mengirim template |
Sebelum launch, jalankan agent terhadap daftar pertanyaan nyata dari riwayat chat toko: cek stok, tanya harga dengan singkatan, pesanan dengan alamat lengkap, komplain, permintaan ongkir, dan dua pesan di luar topik. Setiap kasus harus berakhir dengan jawaban benar yang didukung tool atau dengan handoff, dan tidak satu pun boleh berakhir dengan angka karangan.
Agent UMKM yang baik di WhatsApp sengaja dibuat sempit. Ia hanya membalas saat pelanggan sudah bicara, sehingga tetap di dalam window gratis; ia membaca setiap harga dari data toko sendiri; ia mengingat setiap nomor telepon secara terpisah; dan ia tahu pertanyaan mana yang wajib diserahkan ke manusia. Bangun keempat batasan itu lebih dulu, dan pilihan model menjadi keputusan paling tidak penting di proyek ini.