Agents

Agent Memory and Schedules

Learn about Agent Memory and Schedules in WaymakerOS.

StateSchedulesAlarms

Two things separate an Agent from an Ambassador: it remembers, and it can wake itself up. This covers both.

Memory: ctx.state

Every agent instance gets its own private store. Write to it and the value is still there on the next call — an hour later, a month later, after a redeploy.

await ctx.state.put('stage', 'awaiting-signature')
const stage = await ctx.state.get<string>('stage')   // 'awaiting-signature'
MethodWhat it does
get(key)Read a value. Returns undefined if it was never set.
put(key, value)Write a value. Objects and arrays are fine — no manual serialising.
delete(key)Remove a value. Returns whether it existed.
list({ prefix, limit })Read many keys at once, as an object. Prefix is the useful part.

Prefixes give you collections

There's no separate "list" type — use key prefixes and list():

await ctx.state.put(`msg:${Date.now()}`, { from: 'ana', text: 'can we move Tuesday?' })

const messages = await ctx.state.list({ prefix: 'msg:' })
// { 'msg:1721...': {...}, 'msg:1721...': {...} }

Memory is per tenant, always

The isolation is structural, not something you implement. Agent acme writing stage cannot see or overwrite agent globex's stage. You never write a tenant id into a key to keep them apart — ctx.tenantId tells you who you're serving, but the store is already separate.

What to keep in state

Keep the things that make the next call smarter: conversation history, workflow position, counters, cached lookups, the queue of pending work. Keep business records — customers, invoices, orders — in your tables or your app's database, where the rest of your organisation can see them.

Schedules: ctx.schedule

An agent can set an alarm for itself. When it fires, your onAlarm handler runs.

export default defineAgent({
  async onRequest(request, ctx) {
    await ctx.state.put('pending', 'follow-up')
    await ctx.schedule.in(24 * 60 * 60 * 1000)   // wake me tomorrow
    return new Response('I will check back in a day')
  },

  async onAlarm(ctx) {
    const pending = await ctx.state.get('pending')
    if (!pending) return
    // ...send the follow-up
    await ctx.state.delete('pending')
  }
})
MethodWhat it does
in(ms)Wake after a delay, in milliseconds
at(when)Wake at a specific time — a Date or a timestamp
cancel()Cancel the pending wake-up
next()When the next wake-up is due, or null if none is set

One alarm at a time

Each agent instance has a single alarm slot. Setting a new time replaces the previous one — it doesn't queue a second.

To manage several logical timers, keep them in state and set the alarm for whichever is soonest:

async onAlarm(ctx) {
  const timers = await ctx.state.list<number>({ prefix: 'due:' })
  const now = Date.now()

  for (const [key, dueAt] of Object.entries(timers)) {
    if (dueAt <= now) {
      // ...handle this one
      await ctx.state.delete(key)
    }
  }

  // Re-arm for the next one still outstanding
  const remaining = Object.values(await ctx.state.list<number>({ prefix: 'due:' }))
  if (remaining.length > 0) {
    await ctx.schedule.at(Math.min(...remaining))
  }
}

Schedules vs Ambassador cron

They solve different problems:

Use
Ambassador scheduleFixed, recurring, same for everyone — "run the nightly sync at 2am"
Agent schedulePer tenant, set dynamically in response to something — "chase this client in three days"

If the timing is the same for every customer, it's a cron job on an Ambassador. If each tenant is on its own clock, it's an Agent.

A worked example: a follow-up chaser

export default defineAgent({
  async onRequest(request, ctx) {
    const { action } = await request.json()

    if (action === 'sent') {
      await ctx.state.put('sent_at', Date.now())
      await ctx.state.put('chases', 0)
      await ctx.schedule.in(3 * 24 * 60 * 60 * 1000)   // chase in 3 days
      return new Response('tracking')
    }

    if (action === 'replied') {
      await ctx.schedule.cancel()
      await ctx.state.delete('sent_at')
      return new Response('done — no more chasing')
    }

    return new Response('unknown action', { status: 400 })
  },

  async onAlarm(ctx) {
    const chases = (await ctx.state.get<number>('chases')) ?? 0
    if (chases >= 3) return                      // give up politely

    const note = await ctx.ai.generate({
      prompt: `Write a short, warm follow-up. This is chase number ${chases + 1}.`
    })
    // ...send it

    await ctx.state.put('chases', chases + 1)
    await ctx.schedule.in(3 * 24 * 60 * 60 * 1000)
  }
})

One agent, one instance per client, each on its own clock, each remembering how many times it has already chased. That shape is hard to build any other way — and it's the reason Agents exist.

Next Steps