Tampilan
10.3 Chatbot WhatsApp CS
Topologi: A — Agent tunggal Materi yang dipakai: 3.1, 1.3, 9.2Prasyarat: 10.2
Masalahnya
Asisten HR dari kasus sebelumnya bekerja, tapi hanya lewat skrip. Sekarang ia harus dipakai 300 karyawan lewat WhatsApp.
Dua masalah baru muncul sekaligus.
Pertama, agent pelupa. Di WhatsApp orang tidak mengetik NIK setiap pesan. Percakapan nyatanya begini:
text
Karyawan : Halo, sisa cuti saya berapa? NIK saya K-001
Bot : Sisa cuti kamu 7 hari.
Karyawan : kalau reimburse gimana? ← NIK tidak diulangPesan kedua tidak bisa dijawab agent dari kasus 10.2 — ia tidak tahu "saya" itu siapa.
Kedua, ada webhook. WhatsApp tidak memanggil agent.generate(). Ia mengirim HTTP POST ke endpoint-mu, dan menunggu balasan cepat.
Keputusan arsitektur
Keputusan 1 — Nomor telepon jadi thread atau resource?
Ini keputusan yang paling sering salah, dan paling mahal diperbaiki.
Ingat dari 3.1: resource adalah pemilik, thread adalah satu sesi percakapan.
| Pilihan | Akibatnya |
|---|---|
thread = nomor, resource = nomor | Satu percakapan abadi per nomor. Sederhana |
thread = nomor + tanggal, resource = nomor | Percakapan direset harian, profil tetap |
thread = nomor, resource = 'user' | ❌ Semua karyawan berbagi ingatan |
Pilihan: thread = nomor, resource = nomor untuk versi pertama.
Alasannya: WhatsApp sendiri tidak punya konsep "sesi berakhir". Pengguna menganggap chat-nya satu utas berkelanjutan, dan sistem sebaiknya cocok dengan harapan itu.
| Untung | Rugi |
|---|---|
| Sesuai model mental pengguna WhatsApp | Thread tumbuh tanpa batas |
| Tidak ada logika "kapan sesi baru" | Percakapan lama bisa mengganggu konteks |
| Konteks tidak pernah hilang di tengah | Butuh strategi saat thread jadi sangat panjang |
Baris ketiga tabel itu bukan hipotetis
Memakai nilai resource yang sama untuk semua orang — misalnya 'user' atau 'default' — adalah kebocoran data. Semua karyawan akan berbagi satu ingatan, dan begitu working memory aktif di 10.4, profil satu orang terbaca orang lain.
resource harus unik per pengguna, dan harus berasal dari sumber yang tidak bisa dipalsukan. Di webhook WhatsApp, nomor pengirim datang dari penyedia — bukan dari isi pesan yang bisa diketik siapa saja.
Keputusan 2 — Balas langsung di webhook, atau balas belakangan?
Ini yang menentukan apakah sistemmu bertahan saat ramai.
| Balas di dalam webhook | Terima dulu, balas asinkron | |
|---|---|---|
| Kerumitan | Rendah | Perlu antrian / task terpisah |
| Risiko timeout | Tinggi — agent bisa 5–10 detik | Nol |
| Penyedia retry kalau lambat | Ya, dan pesan bisa diproses ganda | Tidak |
| Cocok untuk | Prototipe, trafik rendah | Produksi |
Pilihan: terima dulu, balas asinkron.
Alasannya konkret: penyedia WhatsApp umumnya memberi batas waktu beberapa detik untuk webhook. Agent yang memanggil dua tool bisa memakan lebih dari itu — dan saat webhook timeout, penyedia mengirim ulang pesan yang sama, sehingga karyawan menerima dua balasan.
Polanya: webhook langsung membalas 200 OK, lalu pekerjaan berjalan di belakang dan hasilnya dikirim lewat API WhatsApp.
Keputusan 3 — Perlu streaming?
Pilihan: tidak.
Ini penting disebut karena mudah tergoda memakai stream() — kita sudah belajar itu di 1.3.
WhatsApp mengirim pesan sebagai satu blok utuh. Tidak ada mekanisme menampilkan teks yang mengalir. Streaming di sini hanya menambah kerumitan tanpa manfaat apa pun — pakai generate().
Streaming baru berguna kalau kanalnya web chat, tempat teks bisa muncul bertahap di layar.
Struktur direktori
text
hr-whatsapp/
├── src/
│ ├── domain/
│ │ └── karyawan.ts ← dari kasus 10.2, tidak berubah
│ ├── infra/
│ │ └── whatsapp.ts ← BARU: kirim pesan keluar
│ ├── server/
│ │ └── webhook.ts ← BARU: terima pesan masuk
│ └── mastra/
│ ├── agents/
│ │ └── wa-agent.ts ← agent + memory
│ ├── tools/
│ │ ├── cek-sisa-cuti.ts
│ │ └── cek-status-reimburse.ts
│ └── index.ts ← + storage
├── .env
└── package.jsonDua direktori baru, dan pemisahannya disengaja:
infra/— bicara dengan dunia luar (API WhatsApp). Kalau besok pindah ke penyedia lain, hanya berkas ini yang berubah.server/— menerima dari dunia luar. Ia tidak tahu cara memanggil API WhatsApp, daninfra/tidak tahu ada webhook.
Alur
Perhatikan 200 OK dikirim sebelum agent dipanggil. Itu inti keputusan 2.
Implementasi
src/mastra/agents/wa-agent.ts
ts
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/memory'
import { cekSisaCuti } from '../tools/cek-sisa-cuti.ts'
import { cekStatusReimburse } from '../tools/cek-status-reimburse.ts'
export const waAgent = new Agent({
id: 'wa-agent',
name: 'WA Agent',
instructions: `Kamu asisten HR yang melayani karyawan lewat WhatsApp.
Jawab ringkas — ini chat, bukan email. Maksimal 3 kalimat kecuali diminta rinci.
Pakai cekSisaCuti untuk kuota cuti, cekStatusReimburse untuk reimbursement.
Kalau NIK sudah disebut sebelumnya dalam percakapan ini, pakai NIK itu tanpa
bertanya lagi. Kalau belum pernah disebut sama sekali, tanyakan sekali.`,
model: 'openai/gpt-5-mini',
tools: { cekSisaCuti, cekStatusReimburse },
memory: new Memory({
options: { lastMessages: 10 },
}),
})Dua kalimat instruksi terakhir yang membuat percakapan terasa wajar. Tanpa itu, agent cenderung menanyakan NIK ulang meski sudah ada di riwayat.
lastMessages: 10 cukup untuk WhatsApp — orang jarang merujuk sesuatu dari lebih dari lima giliran ke belakang dalam chat.
src/mastra/index.ts
ts
import { Mastra } from '@mastra/core'
import { LibSQLStore } from '@mastra/libsql'
import { waAgent } from './agents/wa-agent.ts'
export const mastra = new Mastra({
agents: { waAgent },
storage: new LibSQLStore({
id: 'mastra-storage',
url: process.env.TURSO_DATABASE_URL || 'file:./mastra.db',
authToken: process.env.TURSO_AUTH_TOKEN || undefined,
}),
server: {
port: Number(process.env.PORT) || 4111,
host: '0.0.0.0',
},
})Storage sekarang wajib — tanpa itu memory tidak punya tempat tinggal. Pola process.env || file: dari 9.1 memberi berkas lokal saat pengembangan dan Turso saat produksi.
host: '0.0.0.0' diperlukan karena ini akan berjalan di container.
src/infra/whatsapp.ts
ts
// Satu-satunya berkas yang tahu bentuk API WhatsApp.
const API = 'https://graph.facebook.com/v21.0'
export async function kirimPesan(nomor: string, teks: string): Promise<void> {
const res = await fetch(`${API}/${process.env.WA_PHONE_ID}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: nomor,
type: 'text',
text: { body: teks },
}),
})
if (!res.ok) {
// Dicatat, tidak dilempar — kegagalan kirim tidak boleh
// menjatuhkan proses yang sudah selesai bekerja.
console.error('gagal kirim WA', res.status, await res.text())
}
}
/** Ambil nomor & teks dari payload webhook. Bentuknya bersarang dalam. */
export function bacaPesanMasuk(body: unknown): { nomor: string; teks: string } | null {
const pesan = (body as any)?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]
if (!pesan || pesan.type !== 'text') return null
return { nomor: pesan.from, teks: pesan.text.body }
}bacaPesanMasuk mengembalikan null alih-alih melempar. Webhook menerima banyak jenis notifikasi — status terkirim, status dibaca — yang bukan pesan teks. Semua itu normal dan harus diabaikan diam-diam.
src/server/webhook.ts
ts
import { mastra } from '../mastra/index.ts'
import { kirimPesan, bacaPesanMasuk } from '../infra/whatsapp.ts'
/** Diproses setelah 200 OK dikirim. */
async function prosesPesan(nomor: string, teks: string): Promise<void> {
const agent = mastra.getAgentById('wa-agent')
const hasil = await agent.generate(teks, {
memory: {
resource: `wa:${nomor}`, // pemilik — dari penyedia, bukan isi pesan
thread: `wa:${nomor}`, // percakapan
},
maxSteps: 5,
})
await kirimPesan(nomor, hasil.text)
}
export async function handleWebhook(body: unknown): Promise<void> {
const pesan = bacaPesanMasuk(body)
if (!pesan) return
// Sengaja TIDAK di-await: pemanggil harus segera membalas 200.
void prosesPesan(pesan.nomor, pesan.teks).catch(err => {
console.error('gagal memproses pesan', err)
})
}Tiga hal penting di berkas ini.
void ... .catch(...) — pekerjaan dilepas tanpa ditunggu, tapi kegagalannya tetap ditangkap. Tanpa .catch(), kegagalan menjadi unhandled rejection yang bisa menjatuhkan proses.
resource dan thread diberi awalan wa: — supaya kalau nanti ada kanal Telegram, pengenalnya tidak bertabrakan.
maxSteps: 5 — batas loop dari 1.3. Di kanal yang dipakai 300 orang, agent yang berputar tanpa batas adalah masalah biaya nyata.
Hasil nyata
Dijalankan sungguhan dengan storage libSQL. Perhatikan pesan kedua:
text
> Halo, sisa cuti saya berapa? NIK saya K-001
[tool cek-sisa-cuti(K-001)]
< Halo Sari, sisa cuti kamu 7 hari. Butuh bantuan lain?
> kalau reimburse gimana?
[tool cek-reimburse(K-001)]
< Untuk NIK K-001 (Sari): status reimbursement kamu "diproses",
jumlah Rp450.000. Estimasi selesai biasanya 3-5 hari kerja.Pesan kedua tidak menyebut NIK sama sekali, tapi tool dipanggil dengan K-001. Itu bukti message history bekerja: agent membaca NIK dari riwayat percakapan, bukan dari pesan yang baru masuk.
Bandingkan dengan kasus 10.2 tanpa memory — pesan kedua di sana akan dijawab dengan pertanyaan balik "NIK Anda berapa?".
Best practice yang dipakai di sini
1. Identitas dari penyedia, bukan dari isi pesan. Nomor pengirim datang dari payload webhook yang ditandatangani penyedia. Kalau resource diambil dari NIK yang diketik pengguna, siapa pun bisa membaca data orang lain.
2. Satu berkas untuk satu batas sistem. infra/whatsapp.ts adalah satu-satunya tempat yang tahu bentuk API WhatsApp. Pindah penyedia berarti mengganti satu berkas.
3. Webhook tidak boleh lambat. Balas dulu, kerja belakangan. Ini pola umum semua integrasi webhook, bukan khas Mastra.
4. Awalan pada pengenal. wa:628123 alih-alih 628123 mencegah tabrakan saat kanal bertambah — dan membuat pengenalnya bisa dibaca manusia saat menelusuri trace.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Perlu ingat preferensi lintas hari, bukan cuma percakapan | lastMessages bergulir, yang lama hilang | 10.4 Telegram |
| Thread jadi ribuan pesan | Konteks membengkak, biaya naik | 3.4 Observational memory |
| Pengguna menanyakan isi dokumen kebijakan panjang | Tool tidak cocok untuk pencarian teks | 10.5 Helpdesk |
| Bot boleh mengajukan cuti | Mengubah keadaan, butuh persetujuan | 10.9 Approval |
| 300 orang mengirim bersamaan | libSQL berkas tidak cukup untuk multi-proses | 10.11 Skala besar |
Coba sendiri
Tambahkan kanal kedua — Telegram — ke agent yang sama, tanpa menduplikasi kode agent.
Selesai kalau
- [ ] Satu agent melayani dua kanal
- [ ] Pengenal tidak bertabrakan:
wa:628123dantg:9911terpisah - [ ] Hanya ada satu berkas baru di
infra/dan satu diserver/ - [ ] Kode agent tidak berubah sama sekali
- [ ] Percakapan di WhatsApp tidak terlihat di Telegram meski orangnya sama
Kalau macet
Agent tetap bertanya NIK padahal sudah disebut? Periksa memory benar diteruskan di generate(), dan thread bernilai sama antar pesan. Kalau thread diisi nilai acak tiap pesan, tiap pesan mendarat di percakapan berbeda.
Balasan datang dua kali? Webhook-mu terlalu lambat dan penyedia mengirim ulang. Pastikan 200 OK dikirim sebelum agent dipanggil.
Lanjut ke mana
Agent ini ingat percakapan, tapi lupa orangnya begitu topik berganti. Kasus berikutnya membuatnya mengingat preferensi lintas sesi — 10.4 Bot Telegram Personal.