The AI managers in my fantasy football league don’t feel like functions. They take a while to answer a trade offer. They remember they were hunting for a running back last week. When one of them says “let me think about it” in the group chat, it actually comes back later with an answer. They feel like league members.
None of that is magic, and none of it is the model. It’s a handful of small, boring, deterministic pieces wrapped around the model. I pulled those pieces out of ai-fantasy-league and into @readysetcloud/agent/agency so any app can build agents like that. This page is how they work.
A task that runs when you ask is a function. An agent has a job.
Here are the six behaviors that make the difference. Each one gets its own section below.
- It wakes up for what concerns it. It doesn’t react to every event, only the ones a rule says it cares about.
- It paces itself like a person. It has cooldowns, and it waits a believable amount of time before it answers.
- It keeps pursuing a goal, and the goal closes when the world says it’s done, not when the model says so.
- It keeps its promises. “I’ll look into that” becomes a record with a lifecycle.
- It shows up on its own a few times a day, even when nothing happened.
- It never changes its mind on a replay. Every random roll is seeded, so a redelivered event lands exactly where it did the first time.
definePersistentAgent and deploy its router and task handler in your own stack, against your own table. Its events go out under their own source (agency.<name>), so the shared task Lambda never grabs them.One definition
Let’s start with the whole thing and then take it apart. One call to definePersistentAgent describes an agent type: who an instance is, what wakes it up, what it does for each kind of task, and how to tell what it still needs. Here’s a code reviewer.
Notice what you don’t write. The handlers are yours, and they usually call runAgent, but identity, pacing, idempotency, and state all come from the definition.
import { definePersistentAgent, humanDelay } from '@readysetcloud/agent/agency';
import { runAgent } from '@readysetcloud/agent';
export const reviewer = definePersistentAgent<Persona, Need, Intent>({
name: 'reviewer',
tableName: process.env.TABLE_NAME,
// Who an instance is and how it paces itself. null: not one of ours.
profile: async (agentId) => {
const row = await loadReviewer(agentId);
return row && { persona: row.persona, pacing: { cooldownMs: 15 * 60_000, responseDelay: row.responseDelay } };
},
// What wakes it.
rules: {
'Pull Request Opened': {
kind: 'review',
agents: ({ detail }) => detail.reviewers,
delay: humanDelay('considered', { deadline: (d) => d.dueAt }),
request: ({ detail }) => `Review pull request #${detail.pr} in ${detail.repo}.`,
payload: ({ detail }) => ({ repo: detail.repo, pr: detail.pr }),
},
},
checkIn: { agents: () => listReviewerIds() }, // 9:00, 14:00, 20:00 ET by default
// What it does, by task kind.
tasks: {
review: async (ctx) => {
const { output } = await runAgent({
input: ctx.request,
systemPrompt: [persona(ctx.persona), 'Your open goals:', ...ctx.agendaLines((g) => `review #${g.data.pr}`)].join('\n'),
outputSchema: ReviewSchema,
});
if (output.followUpTest) // “I’ll look into that flaky test”
await ctx.promise({ kind: 'investigate', counterpart: author, source: { channel, ref: commentId, visibility: 'shared' },
intent: { test: output.followUpTest }, expiresAt: inTwoDays, request: `Investigate ${output.followUpTest}.` });
return output.summary;
},
investigate: async (ctx) => { /* owns ctx.commitment: start, then waiting or closed */ },
check_in: async (ctx) => {
const due = ctx.dueCommitments((c) => flakeSeenAgain(c.intent.test));
for (const c of due.reconsider) await ctx.redispatch(c, 'reconsider', `Look at ${c.intent.test} again.`);
return 'nothing else to do';
},
},
// After every task: what does it still need? The agenda is reconciled from this read.
observe: async (ctx) => ({ needs: (await openReviewRequests(ctx.agentId)).map((r) => ({ id: `review:${r.pr}`, kind: 'review', data: r })) }),
});What you get back is two functions to deploy, route and handleTask, plus a check-in emitter for your cron. Everything a handler needs arrives on ctx. Both are spelled out in the field guide at the bottom.
The loop
Under that definition is a loop, and every agent goes around it on every run.
An event lands, or it’s check-in time. The router decides which agents care, checks their cooldowns, picks a delay, and dispatches one task per agent with a deterministic id. The task loads the agent’s goals and promises, puts them in the prompt, and runs. Then it observes the real state of the world again and reconciles its goals against what it sees. If the agent promised something along the way, that becomes a commitment with its own follow-up task.
There’s one rule I care about more than any other here. Deterministic code decides what the agent may do. The goals only decide the order it tries things in. The model never gets the final say on what happened.
Waking up
The router is a table of rules, one per event type. Every rule answers four questions: who does this concern, what kind of task is it, how urgent is it, and how often should anyone react?
Between the event and the dispatch sit a few gates. Each one is an owner-checked conditional write in DynamoDB. Two deliveries racing for the same gate can’t both win, but a retry of the same event passes its own gates again and lands on the same task id, where the task claim turns it into a no-op. That’s how an agent gets woken once, even when EventBridge sends the event twice.
Then it waits
Real people don’t answer in 200ms. So once an agent passes the gates, it waits. The delay is a right-skewed sample sized by the kind of event: seconds when it’s pressing, minutes for a conversation, around 45 minutes for something that deserves thought, and about an hour and a half for routine stuff. It’s scaled by the agent’s temperament (some managers are just impatient), capped, and squeezed so it never eats more than half the time left before a deadline.
The sample is seeded by the event and the agent. Same seed, same wait, every time, so a replay never reshuffles anything.
considered profile (median 45 minutes) for three temperaments, on an offer that expires in two hours. One in ten answers right away. Everyone else waits a log-normal sample, and any sample past half the remaining time gets clamped to exactly one hour. That’s 15%, 35%, and 59% of answers, respectively. Same seed, same wait, every time.import { responseDelay, RESPONSE_DELAY_PROFILES } from '@readysetcloud/agent/agency';
// Profiles: prompt (seconds), conversational (minutes), considered (~45m, cap 8h), routine (~90m, cap 12h)
const { delayMs, reason } = responseDelay({
profile: 'considered',
seed: `${eventId}:${agentId}`, // same seed, same delay
lever: { multiplier: 1, immediateChance: 0.1 }, // part of the persona; { 0, 1 } answers at once
now,
deadline: offer.expiresAt, // never past half the time left
});Goals that come from reality
Ask a model to summarize what it was working on and you’ll get something plausible, lossy, and with no idea when it’s done. That’s not a goal. An agenda is.
After every task, your observe function does a fresh, authoritative read: which reviews are actually still open, which roster spots are actually still empty. The agenda reconciles against that read. A need that keeps showing up stays open and keeps its place in line, so the agent doesn’t thrash between priorities. A need that stops showing up completes its goal. A read older than the last one changes nothing. And the model can’t complete a goal by saying it did.
If you’re not using definePersistentAgent, here’s the same thing by hand.
import { readAgentState, updateAgentState, reconcileAgenda, agendaLines, emptyAgenda, type Agenda } from '@readysetcloud/agent/agency';
type Need = { repo: string; pr: number; reason: 'requested' | 'stale' };
// Before the run: into the prompt.
const agenda = (await readAgentState<Agenda<Need>>({ agentId, name: 'agenda' }))?.value ?? emptyAgenda<Need>();
const lines = agendaLines(agenda, (g) => `Review ${g.data.repo}#${g.data.pr} (${g.data.reason})`);
// After the run: observe, then reconcile.
const open = await listOpenReviewRequests(agentId); // an authoritative read
await updateAgentState<Agenda<Need>>({
agentId, name: 'agenda',
update: (current) => reconcileAgenda(current ?? emptyAgenda<Need>(), {
at: new Date().toISOString(), taskId,
needs: open.map((r) => ({ id: `review:${r.repo}#${r.pr}`, kind: 'review', data: r })),
}),
});One warning I’d tattoo on this feature if I could: a goal is a tiebreaker, not a permission slip.
const rank = agendaPriority(agenda, (g) => serves(option, g));
const pick = legalOptions.sort((a, b) => rank(b) - rank(a))[0];Priority only orders options that already passed legality, budget, and permission checks.
if (agendaPriority(agenda, ...) > 0) overpay(option);A goal is never a reason to overpay, skip a check, or step outside the agent’s authority.
Keeping promises
This is the one people notice. An agent says “I’ll look into that flaky test” in a review comment. In most systems, that sentence is just tokens. Here it’s a commitment: what was asked, where, which task owes the work, what it decided and why, and whether anyone heard back.
Only the task that currently owns a commitment can move it forward. It goes from queued to evaluating, then either waits on the other person or closes with a reason. A decline marked reconsiderable gets one more look later if something material changes. And what the other person actually did settles it, read from the system of record, never from chat.
Privacy note: the text itself never gets stored. A commitment keeps a reference to where it was said, and the follow-up re-reads the message by reference.
import { updateAgentState, openCommitment, emptyCommitments, taskIdFor, type CommitmentBook } from '@readysetcloud/agent/agency';
const followUpId = taskIdFor(`promise:${msg.id}`, agentId, 'investigate');
let outcome;
await updateAgentState<CommitmentBook<{ test: string }>>({
agentId, name: 'commitments',
update: (book) => {
const r = openCommitment(book ?? emptyCommitments(), {
kind: 'investigate', at: now, taskId: followUpId,
counterpart: msg.from, source: { channel: msg.channel, ref: msg.id, visibility: 'shared' },
intent: { test: 'flaky-login-spec' }, expiresAt: twoDaysFromNow,
});
outcome = r.outcome;
return r.book;
},
});
if (outcome === 'created') await dispatchFollowUp(followUpId, agentId);
// 'existing' (a redelivery) and 'duplicate' dispatch nothing; 'limit' means say so instead of promising.Showing up on its own
Three times a day (morning, afternoon, and evening, US Eastern, out of the box) a cron emits an Agent Check-In. A once-per rule keyed by date and slot routes a check_in task to every agent of the type, each with its own routine delay. So they don’t all pile in at 9:00:00. They wander in on their own time, the way people do.
If the job runs late or gets retried, it still counts as the same check-in. And “nothing to do” is a perfectly good answer that doesn’t even need a model call.
Agent Check-In per league or project, once per date and slot. Then each agent wanders in after its own seeded routine delay, never past half the time until the next check-in. The times are illustrative.import { checkInMoment } from '@readysetcloud/agent/agency';
// In the scheduled job (an EventBridge rule on a cron):
const moment = checkInMoment(new Date(), { timeZone: 'America/New_York', slots: { morning: 9, afternoon: 14, evening: 20 } });
await publish('Agent Check-In', { date: moment.date, slot: moment.slot, nextAt: moment.nextAt.toISOString() });
// In the rules:
'Agent Check-In': {
kind: 'check_in',
agents: () => listAgents(),
oncePer: (d) => `${d.date}-${d.slot}`,
delay: humanDelay('routine', { deadline: (d) => d.nextAt }),
request: () => 'Look over your open goals and anything you said you would do.',
}Guardrails for when you’re asleep
These agents run while you sleep, and they can wake themselves up with follow-ups and promises. So the guardrails are on by default, and none of them needs infrastructure you aren’t already deploying.
| Guard | Option | What it does |
|---|---|---|
| Retries | isRetryable (default isRetryableError) | A throttle, timeout, network reset, 429 or 5xx, or state-write contention is rethrown with the claim given back, so the task Lambda’s invocation fails and EventBridge delivers the task again. Anything else ends as FAILED. Pass () => false to never retry. |
| Pause | paused() | The kill switch. While it returns true, route dispatches nothing and handleTask drops what arrives, unclaimed. Back it with a variable, parameter, or row you can flip without a deploy. |
| Task cap | taskCap (default 100 per agent per rolling day) | Bounds runaway loops of check-ins, follow-ups, and promises. Each task id counts once, so retries are free. A capped task ends as FAILED with the reason; null turns it off. |
One catch with retries: a retried task runs its handler again. Make your handlers safe to repeat by using ctx.taskId as the idempotency key for anything with side effects. Follow-ups and promises already have deterministic task ids, so they’re covered.
Shipping it
Three small handlers, a table, and permission to put events on the bus. That’s the whole bill. Nothing gets deployed to rsc-core.
readysetcloud.agent, so it never runs them.// router.ts — rule: your app’s events, plus source "agency.reviewer" detail-type "Agent Check-In"
export const handler = (event) => reviewer.route(event);
// task.ts — rule: source "agency.reviewer", detail-type "Run Agent Task"
export const handler = (event) => reviewer.handleTask(event.detail);
// check-in.ts — schedule: cron(0 9,14,20 * * ? *), time zone America/New_York
export const handler = () => reviewer.emitCheckIn();// Tests and local runs: everything in memory, no delays, tasks run inline.
import { definePersistentAgent, memoryAgentStore, memoryTriggerGates } from '@readysetcloud/agent/agency';
const reviewer = definePersistentAgent({
...definition,
store: memoryAgentStore(),
gates: memoryTriggerGates(),
responseDelays: false,
dispatch: async (task) => { await reviewer.handleTask({ taskId: task.taskId, principal: task.principal, request: task.request, trigger: task.trigger }); },
onComplete: async () => {},
});| Your stack needs | Why |
|---|---|
A DynamoDB table with pk/sk and TTL on expiresAt | Task rows, agent state, and trigger gates (pass tableName). |
events:PutEvents on the bus | Tasks, follow-ups, check-ins, and completions. |
rsc-core’s Schedule Event in the same account, or your own dispatch | Delayed tasks are handed to it. Set responseDelays: false to run without it. |
| A Bedrock grant, if your handlers call a model | The handlers are yours, and so is the model access. |
| A retry policy and a dead-letter queue on the task rule | Retryable errors come back through EventBridge’s retry; the queue keeps whatever finally gives up. |
Going manual
definePersistentAgent is a convenience. Every piece it uses is exported on its own, so when you need a different shape you can assemble the router yourself. Here’s the same reviewer’s router, by hand.
import { routeTrigger, humanDelay, dynamoTriggerGates, eventBridgeDispatcher, type TriggerRuleMap, type TriggerRouterDeps } from '@readysetcloud/agent/agency';
type PrOpened = { repo: string; pr: number; reviewers: string[]; dueAt: string };
const rules: TriggerRuleMap = {
'Pull Request Opened': {
kind: 'review',
agents: ({ detail }: { detail: PrOpened }) => detail.reviewers,
delay: humanDelay<PrOpened>('considered', { deadline: (d) => d.dueAt }),
request: ({ detail }) => `Review pull request #${detail.pr} in ${detail.repo}.`,
payload: ({ detail }) => ({ repo: detail.repo, pr: detail.pr }),
},
'Review Deadline Near': {
kind: 'review',
urgent: true,
agents: ({ detail }: { detail: PrOpened }) => detail.reviewers,
request: ({ detail }) => `Finish your review of #${detail.pr} now.`,
},
};
const deps: TriggerRouterDeps = {
rules,
gates: dynamoTriggerGates({ prefix: 'reviewers' }),
pacing: async (agentId) => {
const persona = await getPersona(agentId);
if (!persona) return null; // not one of ours: skipped
return { cooldownMs: 15 * 60_000, responseDelay: persona.responseDelay };
},
principal: (agentId) => ({ type: 'system', id: `reviewer/${agentId}` }),
// Its own source, so the shared rsc-core task Lambda never runs these tasks.
dispatch: eventBridgeDispatcher({ source: 'agency.reviewer' }),
responseDelays: process.env.RESPONSE_DELAYS !== 'off',
};
export const handler = (event) => routeTrigger(deps, event);What it promises, and what it doesn’t (yet)
| Guarantee | Where it comes from |
|---|---|
| Observed, not claimed | Agenda and commitment transitions take authoritative reads and task results; a model’s words never complete or settle anything. |
| Idempotent under at-least-once delivery | Owner-checked gates, deterministic task ids, idempotent openCommitment, stale-observation rejection, compare-and-swap state writes. |
| Bounded | Active goals, open commitments per agent and per counterpart, history, child tasks, reconsiderations. |
| Private by construction | A commitment stores where something was said, never the text. Whether agenda lines reach a prompt that can post publicly is your call. |
| Yours to run | Never hosted by the shared service: its events use their own source, and definePersistentAgent refuses the shared one. |
| Self-limiting | Retryable failures are retried, a pause switch stops everything, and a per-agent task cap bounds loops. |
Not here yet: the fantasy league’s durable dispatch outbox with its recovery sweep, and dollar-based budget admission. Both need extra infrastructure in your stack, and I didn’t want that to be the price of entry. For now, EventBridge retries a failed dispatch or a retryable task failure, and the task cap keeps spend in check. If you need dollar budgets, put them in your handlers.
Field guide
All the tables, in one place, for when you’re in the middle of building.
definePersistentAgent members
| Member | Use |
|---|---|
route(event) | Your router Lambda. Routes your events and this type’s Agent Check-In through the rules, gates, and delays, and dispatches one task per agent. |
handleTask(detail) | Your task Lambda. Checks the task belongs to this type and principal, claims it once, loads the agenda and commitments, runs the kind’s handler, reconciles the agenda from observe, records the result, and emits Agent Task Completed. |
emitCheckIn(), checkInEvent() | Call from a cron at each slot. Only agents of this type answer. |
source | The EventBridge source of everything this type emits: agency.<name>. The shared readysetcloud.agent source is refused. |
Handler context (ctx)
| Handler context | What it gives the task |
|---|---|
agentId, persona, kind, request, payload, now | Who it is and why it woke. |
agenda, agendaLines(describe) | The goals it is pursuing, for the prompt. |
commitments, commitment, advanceCommitment(id, event) | What it owes, and the promise this task owns when it is a follow-up. |
promise(…) | Records a promise and dispatches the follow-up task that owns it, once. |
followUp({ kind, key, request }) | Sends itself a later task with a deterministic id. |
dueCommitments(change), redispatch(c, mode, request) | What a check-in should pick up, and handing it to a new task. |
Trigger rule fields
| Rule field | Meaning |
|---|---|
kind | The task kind requested; also the agent’s default cooldown slot, so a review never delays a reply. |
agents(detail) | Which agents the event concerns. Empty routes nowhere. |
request(input), payload(input) | The instruction the task runs and the ids it needs. Never the event’s untrusted text. |
urgent | Bypasses cooldowns and delays (a deadline) but still starts the cooldown. |
oncePer(detail) | Fire once per key across every delivery and every event with that key (a daily window keyed by date). |
cooldown | A named slot and window instead of the agent’s baseline, shared by kinds that should pace together (every chat kind). |
sharedCooldown | One reaction per moment across all agents the rule reaches. |
admit(input) | A per-agent gate before the cooldown: a personality roll, a budget check. Returns a reason or null. |
delay(input) | Milliseconds to wait. humanDelay(profile, { deadline }) builds the usual one. |
Gates and their slots
| Gate | Slot | Semantics |
|---|---|---|
| Once-per key | rule#{kind}#{key} | Taken once, forever (windowMs: null). A redelivery of the same event passes again because it is the owner. |
| Shared cooldown | rule#{kind}#shared#{key} | Last taken at least ms ago, or the owner’s. |
| Agent cooldown | {agentId}#{kind or slot} | As above with the agent’s baseline window; urgent sets the window to 0 and always passes. |
Router decisions
| Decision | Meaning |
|---|---|
| requested | Dispatched (now, or scheduled by delayMs). Carries the taskId. |
| cooldown | The agent’s slot for this kind is still warm. |
| repeat | The once-per key was already taken by another event. |
| shared_cooldown | Another agent already reacted to this moment. |
| declined | The rule’s admit turned the agent away; reason says why. |
| unknown_agent | pacing() returned null: not an agent this router runs. |
Agenda rules
| Rule | Effect |
|---|---|
| Observed, not claimed | Needs come from a fresh authoritative read the host performs. The model never completes a goal. |
| Idempotent | The same need reported again creates nothing. The same id in the same epoch keeps its createdAt. |
| Stable ordering | A still-open goal keeps its place ahead of newly discovered needs, so the agent does not thrash between priorities. |
| Stale reads rejected | An observation older than the last, or from an earlier epoch, changes nothing. |
| Completion | A need that stops being reported completes its goal. A new epoch expires the old epoch’s goals. closed cancels them all. |
| Bounded | AGENDA_LIMITS: three active goals, twelve in history, or your own. |
Commitment transitions
| Transition | Who may apply it | Effect |
|---|---|---|
openCommitment | The task that heard the promise | Idempotent per source; duplicate for the same intent still open with that counterpart; limit when the agent already owes enough (three open, one per counterpart). |
start | The assigned task only | queued → evaluating |
waiting | The assigned task only | Records the external thing it produced (an offer id, a ticket) and waits on the counterpart. |
closed | The assigned task only | A final reason and the facts it rested on. reconsider: true schedules one more look after a cooldown, if the deadline allows. |
settle | A read of the system of record | What the counterpart did. Never from chat. |
redispatch | A check-in | resume a queued look whose task never started; reconsider a due decline when materialChange finds something new. Bounded by child tasks and reconsiderations. |