Postera
2026 — A Go library that lets an AI agent wake itself at any moment in the future
01 Problem
An AI agent is a language model given a written instruction and a list of tools, functions the program runs when the model asks for them by name. It only runs when something calls it, usually a person sending a message. So when an agent promises to follow up tomorrow morning, or to check back in an hour, it means it, and then nothing happens, because nothing calls it tomorrow morning.
People keep promises like that with prospective memory, the kind of memory that remembers to do something later, like taking medicine at eight. An agent needs the same, a way to leave itself a note and be woken by it at the right moment.
I needed this for Ava, the lead agent of Avagenc Chat, a group chat of AI agents I build at Avagenc. I started Postera on 26 April 2026, and Ava first used it four days later, on 30 April.
02 Solution
go.trueardian.com/postera is an open source Go library that gives an agent that note, a library being code other programs import rather than a program that runs on its own. The agent writes a posterum, Latin for what comes after, a message addressed to its future self for a moment it picks. When that moment comes, the message is delivered back, and the agent wakes up and acts on it.
Waking an agent later takes two things. Something has to keep the note until it is due, and Postera calls that the store. Something has to call the agent back at that moment, and Postera calls that the queue. Between the agent and those two sits Postarius, the part the agent talks to. The next sections take them in that order, the store, the queue, and then Postarius.
go get go.trueardian.com/postera03 Store
The store is where a posterum waits until it is due. It keeps the ID, the message, who the posterum belongs to, any extra metadata, the moment it is due, and when it was written. Postera describes the store as an interface, a set of methods any type can provide, so a store can sit on any database. It has four methods, Save, Get, Remove, and List, and two rules. Get answers ErrNotFound for an ID it does not know, and List treats an empty field of the query as no filter.
Postera comes with one store, the postgres package, which keeps each posterum as a row in a table named postera. WithAutoMigrate creates the table at startup, and WithRetry tries again after the failures that pass on a second try, like a dropped connection. Until 22 October 2026 the table had been reshaped by five migrations, and all five ran again on every startup. They became one schema that is safe to run again, under a lock that lets one instance run it while the others wait.
type Store interface {
Save(ctx context.Context, p Posterum) error
Get(ctx context.Context, id string) (Posterum, error)
Remove(ctx context.Context, id string) error
List(ctx context.Context, q Query) ([]Posterum, error)
}store, err := postgres.NewStore(ctx, pool,
postgres.WithAutoMigrate(),
postgres.WithRetry(3, 100*time.Millisecond),
)04 Queue
A store only remembers. The queue is what calls the agent back at the moment a posterum is due, and it is an interface too, with two methods. Enqueue schedules the call and Cancel takes it back. The call carries the message, along with whose it is, so whatever wakes the agent can put it back in the right conversation.
Postera comes with one queue, the cloudtasks package, on Google Cloud Tasks, a Google Cloud service that holds a task until its time and then makes an HTTP request, a call to a web address. It POSTs the message as the body of a request to the endpoint that wakes the agent and sends the human, the agent, and the session as headers. It can also attach an OIDC token, a proof signed by Google, so the endpoint can check that the call really came from Cloud Tasks.
Two of its rules come from Cloud Tasks itself. Each task is named after its posterum, so queueing the same posterum twice still makes one task, and on 20 May 2026 those names were refused because the IDs ended in the == padding of base64, which a task name cannot hold. Every ID now is pstr_ followed by 26 random letters and digits. Since 24 July it only takes a time between 29 seconds and 29 days from now, because Cloud Tasks refuses anything past 30 days and a sooner task could fire before the app has finished writing what led to it. A time outside that window comes back as ErrScheduleOutOfRange, so the agent can be told to pick another one.
type Queue interface {
Enqueue(ctx context.Context, p Posterum) error
Cancel(ctx context.Context, id string) error
}queue, err := cloudtasks.NewQueue(tasksClient, "my-project", "asia-southeast2", "postera",
"https://agent.example.com/wake",
cloudtasks.WithServiceAccountEmail("tasks@my-project.iam.gserviceaccount.com"),
cloudtasks.WithHumanHeader("X-Postera-Human"),
cloudtasks.WithAgentHeader("X-Postera-Agent"),
cloudtasks.WithSessionHeader("X-Postera-Session"),
)05 Postarius
Postarius ties the store and the queue together, and it is the only part the agent talks to. It lives in the postera package, which imports nothing beyond Go's standard library. The postgres and cloudtasks packages each import postera and not the other way round, so an app imports only the ones it uses, or writes its own store or queue against the two interfaces.
New takes a store, a queue, and options that say where to find the timezone and who is calling. From there the agent has three calls. Create writes a posterum, ListUpcoming reads the ones still to come, and Cancel takes one back. The message is read by a future self that remembers nothing of this conversation, so it carries the who, the what, and the why.
It took this shape over time. It began as go.naturallyfunny.dev/postera. On 30 May 2026 its tools for Google's Agent Development Kit moved out into a module of their own, now agentkit, so the core stays free of any agent framework. On 12 June the separate agent layer that wrapped Postarius was merged into it, so the type the agent talks to is the library itself, and on 5 October the module moved to go.trueardian.com.
Create does three things in order. It reads the time the agent wrote, it reads who is asking, and it queues and saves the posterum. The next three sections take them one at a time.
postgres ──┬─▶ postera
cloudtasks ──┘postarius, err := postera.New(store, queue,
postera.WithTimezoneFromContext(timezoneKey{}),
postera.WithHumanFromContext(humanKey{}),
postera.WithAgentFromContext(agentKey{}),
postera.WithSessionFromContext(sessionKey{}),
)p, err := postarius.Create(ctx, postera.CreateArgs{
Message: "Follow up on whether they submitted the Q3 report. They were waiting on their manager's approval.",
TriggerAt: "2026-11-06T09:00:00",
})
upcoming, err := postarius.ListUpcoming(ctx)
err = postarius.Cancel(ctx, p.ID)06 Timezone handling
First, Create turns the time the agent wrote into a moment the queue can use. A moment can be written two ways. An instant names one point for the whole world, like 02:00 UTC, while a wall-clock time names what a clock shows somewhere, like 9am, and means nothing until you know where. People and agents think in wall-clock time, and language models are bad at the arithmetic that turns one into the other.
So TriggerAt is a wall-clock time with no offset, like 2026-11-06T09:00:00, and Postera does the conversion. The zone comes from the context, the value Go hands along with every request for things like who is asking. It is an IANA name, one of the standard names like Asia/Jakarta that know every daylight saving rule, or it comes from WithDefaultTimezone when the whole app lives in one zone. With neither, Create fails, because scheduling into an unknown zone is a mistake and not something to guess.
LocalizesFromContext, added on 13 June 2026, tells the code around the agent that the zone is read per request, so it can honestly tell the model that no conversion is needed. LocationFromContext hands that zone back, to show times in the frame the agent wrote them in. Until 22 October it fell back to UTC when the zone was missing or wrong, so a time could be shown hours off without a word. Since then it fails exactly when Create would.
07 Identity scoping
Next, Create reads who the posterum is for, from the same context. One app usually serves many people, and often several agents, so Postera keeps three things about each posterum, the human it is for, the agent that wrote it, and the session, the conversation it came from. Middleware, the code that runs before every request, puts them in the context once. Create stamps them onto the posterum, ListUpcoming returns only the caller's, and Cancel only finds the caller's.
None of the three calls takes them as a parameter, because the model fills in the parameters of a tool, and a model given that choice could write for someone else. It can only write the message and the time.
Since 12 June 2026 a posterum outside the caller's scope comes back from Cancel as ErrNotFound, the same as one that does not exist, so nobody can learn which IDs belong to someone else. Until 22 October the two errors still read differently, since a missing one carried the store's own message inside it and an out-of-scope one did not, and now both come back the same.
This is not access control, and it would be lying if it pretended to be. Whoever holds the store holds the database, so deciding who may do what belongs above the library, where the app signs people in. A caller with nothing in the context, like the app's own maintenance code, sees every posterum.
Forgetting to fill the context is the easy mistake, so WithLogger, added on 12 June, logs a warning whenever an identity key is set up but its value is empty. It only warns and never stops the call.
08 Atomicity
Last, Create queues the task and saves the row, so a posterum now lives in two places, a row in the store that remembers it and a task in the queue that will fire it. The two share no transaction, the guarantee that several writes all happen or none do, so one can succeed while the other fails. A task with no row wakes the agent about a note nobody can list or cancel, and a row with no task is a promise that never fires.
So each call undoes its first step when the second one fails. Create queues the task and then saves the row, and when the save fails it cancels the task. Cancel removes the row and then the task, and when the task will not go, it saves the row back. When the undo fails too, both errors come back joined, never just one of them.
Cancel used to work the other way round. It cancelled the task first, and when the row would not go, it queued the task again. On 24 July 2026 it stopped doing that for a posterum already due, which would have fired at once. On 22 October it turned out the undo had never worked, because Cloud Tasks refuses a task name that was just deleted and answers that the task already exists, which the queue counts as success. So Cancel now starts with the row, since a row can always be written back exactly.
- Your code using this library calls postarius.Create(ctx, postera.CreateArgs{Message: "Follow up on whether they submitted the Q3 report…", TriggerAt: "2026-11-06T09:00:00"})
- Then this library read your user's timezone from the context and turned 9am there into UTC (Asia/Jakarta, 2026-11-06T02:00:00Z)
- Then this library queued a Cloud Tasks task, named after the new ID, to call the wake endpoint at that moment (POST https://agent.example.com/wake)
- Then this library saved the posterum as a row in Postgres (1 row in postera)
- The code results in the posterum, due at 9am in Jakarta (p.ID "pstr_4KQ7ZJX2M5TNRB3WHCYD6PLVEA", err nil)
- Your code using this library calls postarius.Create(ctx, postera.CreateArgs{Message: "Follow up on whether they submitted the Q3 report…", TriggerAt: "2026-11-06T09:00:00"})
- Then this library read the timezone and queued the task as before (POST https://agent.example.com/wake)
- Then this library tried to save the row, and Postgres refused the connection (dial tcp 10.0.0.5:5432: connect: connection refused)
- Then this library cancelled the task it had just queued, so nothing fires without a row (DeleteTask …/queues/postera/tasks/pstr_7WQ2N5RJX4HMD3KTBYCZ6LPVGE)
- The code results in the save error, with nothing left behind (postera: create: postgres: save pstr_7WQ2N5RJX4HMD3KTBYCZ6LPVGE: failed to connect)
if err := p.queue.Enqueue(ctx, posterum); err != nil {
return Posterum{}, fmt.Errorf("postera: create: %w", err)
}
if err := p.store.Save(ctx, posterum); err != nil {
if rollbackErr := p.queue.Cancel(context.WithoutCancel(ctx), posterum.ID); rollbackErr != nil {
return Posterum{}, errors.Join(
fmt.Errorf("postera: create: %w", err),
fmt.Errorf("postera: create: rollback: %w", rollbackErr),
)
}
return Posterum{}, fmt.Errorf("postera: create: %w", err)
}09 Error handling
Every error from Postera starts with where it was raised, like postera: create, and wraps the error beneath it, so errors.Is, Go's way of asking whether an error is a known one, still finds it.
Two errors are named. ErrNotFound is a posterum that does not exist or is not the caller's, and ErrScheduleOutOfRange is a time the queue will not take. The others, a missing timezone, a zone name that does not exist, or a TriggerAt in the wrong format, say what a valid value looks like, like Asia/Jakarta or 2024-01-15T09:00:00, so a model that gets one back can fix its call.
p, err := postarius.Create(ctx, args)
if errors.Is(err, postera.ErrScheduleOutOfRange) {
w.WriteHeader(http.StatusBadRequest)
}
err = postarius.Cancel(ctx, id)
if errors.Is(err, postera.ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
}10 Limits
It does not run the wake. Postera schedules it and Cloud Tasks makes the call, and what the agent does once it is woken is up to the app.
It has no recurring schedules. A posterum is one moment, so an agent that wants every morning writes the next one each time it wakes.
It has no metrics or tracing, and only two adapters, Postgres and Cloud Tasks. A Store is four methods and a Queue is two, so another one is small to write.
Its schema runs again on every startup instead of each change running once, and version-tracked migrations are on the roadmap.
11 Use cases
agentkit, my open source library that turns Go clients into tools for Google's Agent Development Kit, gives an agent three tools on top of Postera, wake_future_self, list_upcoming_wakes, and cancel_upcoming_wake, with what the model reads about each one already written.
Ava, the lead agent of Avagenc Chat, wakes herself through those tools. Her Postarius reads the person and their timezone from each request and copies the timezone into the posterum. Cloud Tasks carries both back as headers, with a fixed API key, to an endpoint that runs Ava again in the person's thread under the speaker name postera, so she knows the message comes from her past self and not from the person.
Her first wakes were dropped, because Cloud Tasks sends the message as the raw body and her endpoint expected JSON. On 1 May 2026 the endpoint began reading the body as plain text.
Avagenc Chat's Postera sheet shows a person what Ava has lined up for them, read through ListUpcoming, and its API takes one back through Cancel after checking who is signed in.
import posteraadk "go.trueardian.com/agentkit/postera/adk"
tools, err := posteraadk.Tools(postarius)Postera
Any Go agent comes back to things on its own with three calls, woken at the local time it wrote, and a call that fails halfway is undone instead of left half done