Skip to content

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 eksternalmastra.schedules
Bertahan setelah restartYa (cron OS) / Tidak (in-process)Ya, dipersistensi
Terlihat di StudioTidakYa
Diubah saat runtimePerlu deploy ulangLewat API
Riwayat pemicuBuat sendiriBawaan
KetergantunganPaket tambahanSudah 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
KonteksBersih tiap kaliMenumpuk
BiayaTetapNaik seiring riwayat
Bisa merujuk laporan kemarinTidakYa
Cocok untukLaporan berdiri sendiriPercakapan 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 scheduleLewat tool
Data selalu segarTidak — prompt statisYa
Agent bisa memilih dataTidakYa
Jumlah panggilan model12+
Kalau API pasar matiSchedule tetap jalan dengan data basiAgent 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.json

setup-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

SituasiKenapa gagalKe mana
Pemicunya harga, bukan waktuCron tidak tahu apa-apa soal harga10.7 Alert kondisional
Laporan butuh 10 menit diprosesMelewati batas eksekusi wajarBackground task, 7.1
Tiap pengguna punya jadwal sendiriRatusan schedule perlu dikelola per pengguna10.10 Multi-tenant
Deploy serverlessProses mati, scheduler ikut mati9.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

  • [ ] nextFireAt yang 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.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.