Tampilan
10.6 Laporan Saham Terjadwal
Topologi: A — Agent tunggal Materi yang dipakai: 7.2 SchedulesPrasyarat: 10.3
Masalahnya
Lima kasus sebelumnya punya satu kesamaan: agent menunggu ditanya.
Sekarang kebutuhannya terbalik. Tim investasi ingin ringkasan pergerakan IHSG otomatis terkirim ke grup Telegram setiap hari kerja pukul 11.00 — tanpa ada yang memintanya.
Tidak ada pengguna yang mengirim pesan. Pemicunya waktu.
Keputusan arsitektur
Keputusan 1 — Cron eksternal atau schedules Mastra?
Godaan pertama: pakai node-cron, atau cron sistem, lalu panggil agent dari situ.
| Cron eksternal | mastra.schedules | |
|---|---|---|
| Bertahan setelah restart | Ya (cron OS) / Tidak (in-process) | Ya, dipersistensi |
| Terlihat di Studio | Tidak | Ya |
| Diubah saat runtime | Perlu deploy ulang | Lewat API |
| Riwayat pemicu | Buat sendiri | Bawaan |
| Ketergantungan | Paket tambahan | Sudah ada |
Pilihan: mastra.schedules.
Alasan yang menentukan adalah baris pertama. node-cron yang berjalan in-process hilang begitu proses restart — dan deployment berarti restart. Kamu akan menemukan laporan tidak terkirim di hari kamu men-deploy sesuatu.
Schedules Mastra dipersistensi di storage, jadi ia bertahan melewati restart dan redeploy.
Perlu adapter yang mendukung domain schedules
Ingat dari 9.1: dukungan adapter berbeda per domain. Sebelum merancang di atas schedules, pastikan adapter storage-mu mengimplementasikan domain itu.
Keputusan 2 — Run terisolasi atau kirim ke thread?
Schedules bisa berjalan dalam dua bentuk, seperti dibahas di 7.2.
Tanpa thread (agent.generate()) | Signal ke thread | |
|---|---|---|
| Konteks | Bersih tiap kali | Menumpuk |
| Biaya | Tetap | Naik seiring riwayat |
| Bisa merujuk laporan kemarin | Tidak | Ya |
| Cocok untuk | Laporan berdiri sendiri | Percakapan berkelanjutan |
Pilihan: tanpa thread.
Laporan harian tidak perlu mengingat laporan kemarin — datanya diambil segar dari API tiap kali. Memakai thread berarti membayar riwayat yang menumpuk tanpa manfaat, dan setelah 200 hari konteksnya jadi masalah tersendiri.
Kalau nanti dibutuhkan perbandingan dengan hari sebelumnya, ambil dari database — bukan dari riwayat percakapan. Ini penerapan aturan "memory bukan sumber kebenaran" dari pengantar bagian ini.
Keputusan 3 — Data pasar: di prompt atau lewat tool?
Sisipkan di prompt schedule | Lewat tool | |
|---|---|---|
| Data selalu segar | Tidak — prompt statis | Ya |
| Agent bisa memilih data | Tidak | Ya |
| Jumlah panggilan model | 1 | 2+ |
| Kalau API pasar mati | Schedule tetap jalan dengan data basi | Agent tahu dan bisa melapor |
Pilihan: lewat tool.
Prompt schedule bersifat statis — ia ditulis sekali saat schedule dibuat. Menyisipkan data pasar di sana mustahil karena datanya berubah tiap hari.
Struktur direktori
text
laporan-saham/
├── src/
│ ├── domain/
│ │ └── pasar.ts ← ambil & hitung data pasar
│ ├── infra/
│ │ ├── bursa.ts ← klien API bursa
│ │ └── telegram.ts ← kirim ke grup
│ ├── mastra/
│ │ ├── agents/
│ │ │ └── analis-agent.ts
│ │ ├── tools/
│ │ │ ├── ambil-ringkasan-pasar.ts
│ │ │ └── kirim-ke-grup.ts
│ │ └── index.ts
│ └── setup-jadwal.ts ← dijalankan sekali untuk mendaftarkan jadwal
└── package.jsonsetup-jadwal.ts berdiri sendiri karena ia bukan bagian dari alur runtime — ia dijalankan sekali untuk mendaftarkan jadwal, lalu tidak disentuh lagi.
Alur
Perhatikan agent memanggil dua tool: satu mengambil data, satu mengirim hasil. Agent tidak tahu apa itu Telegram — ia hanya tahu ada tool bernama kirimKeGrup.
Implementasi
src/setup-jadwal.ts
ts
import { mastra } from './mastra/index.ts'
// Dijalankan sekali: npx tsx src/setup-jadwal.ts
const jadwal = await mastra.schedules.create({
agentId: 'analis-agent',
cron: '0 11 * * 1-5', // 11:00, Senin-Jumat
prompt: 'Buat ringkasan pergerakan IHSG hari ini dalam 3 poin, lalu kirim ke grup.',
})
console.log('jadwal dibuat:', jadwal.id)
console.log('menyala berikutnya:', new Date(jadwal.nextFireAt).toLocaleString('id-ID'))src/mastra/tools/ambil-ringkasan-pasar.ts
ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { ringkasanHariIni } from '../../domain/pasar.ts'
export const ambilRingkasanPasar = createTool({
id: 'ambil-ringkasan-pasar',
description:
'Ambil data penutupan IHSG hari ini beserta saham naik dan turun terbesar. ' +
'Pakai sebelum menyusun laporan. BUKAN untuk harga satu saham tertentu.',
inputSchema: z.object({}),
outputSchema: z.object({
status: z.enum(['ok', 'pasar-tutup', 'api-gangguan']),
indeks: z.number().nullable(),
perubahanPersen: z.number().nullable(),
topGainer: z.array(z.object({ ticker: z.string(), persen: z.number() })).nullable(),
topLoser: z.array(z.object({ ticker: z.string(), persen: z.number() })).nullable(),
}),
execute: async () => {
const h = await ringkasanHariIni()
if (h.status !== 'ok') {
return { status: h.status, indeks: null, perubahanPersen: null, topGainer: null, topLoser: null }
}
return {
status: 'ok' as const,
indeks: h.indeks,
perubahanPersen: h.perubahanPersen,
topGainer: h.topGainer,
topLoser: h.topLoser,
}
},
})Field status memuat 'pasar-tutup' sebagai keadaan sah, bukan error. Hari libur bursa adalah kejadian normal — dan agent harus bisa melaporkannya alih-alih gagal.
src/mastra/agents/analis-agent.ts
ts
import { Agent } from '@mastra/core/agent'
import { ambilRingkasanPasar } from '../tools/ambil-ringkasan-pasar.ts'
import { kirimKeGrup } from '../tools/kirim-ke-grup.ts'
export const analisAgent = new Agent({
id: 'analis-agent',
name: 'Analis Pasar',
instructions: `Kamu analis pasar saham Indonesia.
Alur kerjamu:
1. Panggil ambilRingkasanPasar untuk data hari ini.
2. Susun ringkasan TEPAT 3 poin: arah indeks, penggerak utama, catatan penting.
3. Panggil kirimKeGrup dengan ringkasan itu.
Kalau status "pasar-tutup", kirim pesan singkat bahwa bursa libur — jangan
menyusun analisis.
Kalau status "api-gangguan", kirim pesan bahwa data belum tersedia dan akan
dicoba lagi besok.
Jangan pernah menyebut angka yang tidak berasal dari tool.`,
model: 'openai/gpt-5-mini',
tools: { ambilRingkasanPasar, kirimKeGrup },
})Instruksi ini menyebutkan ketiga cabang status secara eksplisit. Tanpa itu, agent akan mengarang laporan saat pasar tutup — kegagalan yang baru ketahuan saat hari libur nasional.
Hasil nyata
Pendaftaran jadwal dijalankan sungguhan:
text
DIBUAT: {
"id": "agent_20955d5b-5461-46f7-92c3-dece2e4c3cee",
"agentId": "laporan-saham",
"prompt": "Buat ringkasan pergerakan IHSG hari ini dalam 3 poin.",
"cron": "0 11 * * 1-5",
"status": "active",
"nextFireAt": 1788753600000,
"createdAt": 1788700390620,
"updatedAt": 1788700390620
}Terjemahan nextFireAt — dan ini pemeriksaan yang layak selalu kamu lakukan:
text
dibuat pada : 6/9/2026, 20.13 WIB (Minggu malam)
menyala pada : 7/9/2026, 11.00 WIB (Senin pagi)Cron 0 11 * * 1-5 benar melompati akhir pekan dan mendarat di hari kerja berikutnya pukul 11.00. Kalau nextFireAt menunjukkan waktu yang tidak kamu harapkan, itu hampir selalu soal zona waktu — periksa sebelum mengandalkannya.
Zona waktu adalah sumber bug nomor satu pada schedule
Cron dievaluasi di zona waktu server, bukan zona waktu penggunamu. Server di UTC akan menyalakan 0 11 * * * pada pukul 18.00 WIB, bukan 11.00.
Selalu cetak nextFireAt dalam zona waktu lokal setelah membuat jadwal. Sepuluh detik pemeriksaan itu menghemat satu hari kebingungan.
Jadwal juga bisa dibaca kembali kapan saja:
text
DAFTAR: [
{
"id": "agent_20955d5b-...",
"agentId": "laporan-saham",
"cron": "0 11 * * 1-5",
"status": "active",
"nextFireAt": 1788753600000,
...
}
]Karena ini datang dari storage, daftar yang sama muncul setelah restart.
Best practice yang dipakai di sini
1. Pendaftaran jadwal di berkas terpisah. Ia dijalankan sekali, bukan bagian dari alur runtime. Mencampurnya ke index.ts berarti jadwal dibuat ulang tiap startup.
2. Verifikasi nextFireAt dalam waktu lokal. Selalu, tanpa kecuali.
3. Status "normal tapi bukan sukses" masuk enum. pasar-tutup bukan error — ia keadaan sah yang harus ditangani berbeda.
4. Agent tidak tahu kanal pengirimannya. Ia memanggil kirimKeGrup. Pindah dari Telegram ke Slack berarti mengganti isi tool, bukan instruksi agent.
5. Cron mengecualikan akhir pekan di level cron, bukan di dalam agent. 1-5 lebih murah dan lebih jelas daripada agent yang menyala tiap hari lalu memutuskan tidak melakukan apa-apa.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Pemicunya harga, bukan waktu | Cron tidak tahu apa-apa soal harga | 10.7 Alert kondisional |
| Laporan butuh 10 menit diproses | Melewati batas eksekusi wajar | Background task, 7.1 |
| Tiap pengguna punya jadwal sendiri | Ratusan schedule perlu dikelola per pengguna | 10.10 Multi-tenant |
| Deploy serverless | Proses mati, scheduler ikut mati | 9.4 |
Baris terakhir penting dan sering terlambat disadari. Scheduler butuh proses yang tetap hidup. Di platform serverless yang mematikan proses setelah tiap permintaan, schedules Mastra tidak akan menyala.
Coba sendiri
Buat jadwal pengingat standup harian ke grup Telegram tiap hari kerja pukul 09.15, berisi daftar tugas yang masih terbuka.
Selesai kalau
- [ ]
nextFireAtyang tercetak sesuai zona waktumu — verifikasi manual - [ ] Jadwal masih ada setelah proses dimatikan dan dijalankan ulang
- [ ] Akhir pekan dilewati oleh cron, bukan oleh logika di dalam agent
- [ ] Saat sumber data mati, pesan yang terkirim menjelaskan itu — bukan laporan karangan
- [ ] Kamu bisa mematikan jadwal lewat API tanpa deploy ulang
Kalau macet
Jadwal tidak pernah menyala? Tiga penyebab paling umum, berurutan: zona waktu server berbeda dari dugaanmu; storage adapter tidak mendukung domain schedules; atau proses tidak tetap hidup.
Jadwal ganda setelah restart? Kode pendaftaran ikut jalan saat startup. Pindahkan ke berkas terpisah yang dijalankan manual.
Lanjut ke mana
Jadwal bekerja untuk "setiap hari jam 11". Tapi kebutuhan yang lebih sering muncul justru "kabari saya kalau BBCA menyentuh 123" — pemicunya bukan waktu, melainkan peristiwa: 10.7 Alert Harga Kondisional.