One agent per user: how penpal is built on Maritime
penpal gives every learner a private OpenClaw agent on Maritime: 24 personas, one call to provision, and a friendship that lives on the agent's own disk.
penpal is a language app: you pick one of 24 characters, and they text you through the day in the language you are learning. They remember your job, your dog, and the interview you were nervous about last week. Corrections never interrupt; they arrive at night as a short note.
Every user gets their own dedicated agent on Maritime. Not one shared model with a user id in the prompt: a private OpenClaw agent on its own machine, with its own disk, doing one job for one person.
One developer took it from first commit to a live product with accounts, phone verification, SMS in and out, nightly lessons, streaks and 24 personas in nine days. The company-side stack is one Next.js app and one Postgres. Everything that would usually be the hard part is an API call. Here is what that actually looks like in code, including the parts that cost something.
Why one agent per user
Most AI products multiplex one model across every customer and keep memory as rows to be retrieved and re-injected into the prompt. For a tool, that is correct. Nobody wants a dedicated machine to summarize a PDF.
For a relationship, it fights the shape of the problem. A friendship is state: the running joke, the ongoing argument about where to eat, the fact that you said the interview was Thursday and it is now Friday. Reassembling that from a vector search on every request is a lot of work to approximate something a computer with a disk gets for free.
So each friend is an OpenClaw agent in its own container or microVM, and the memory of the friendship lives in that agent's state directory. penpal never manages context windows or embeddings for personality continuity. It sends the agent a message.
Provisioning is one call and one ordering decision
The call, at the end of the signup wizard:
const agent = await maritime.agents.provision({
name: `penpal-${user.id.slice(0, 8)}`,
template: 'openclaw',
externalId: `penpal-${user.id}`,
instructions: buildInstructions(user.name, user.settings),
})externalId is what makes that safe to retry: a second provision for the same user returns the same agent instead of creating a second friend. Signup can fail halfway, a background job can re-run it later, and nobody ends up with two Sofías.
The ordering around it is the part worth stealing. A usable friend needs five things beyond the bare agent: its own email identity, a model set, control credentials in its environment, a skill installed, and a restart to pick all of that up. The obvious implementation does them in sequence and then says hello, which puts a new user behind the slowest step and lets a slow restart mark a working agent as failed.
penpal blocks on the minimum instead: provision, wait for the agent to be stable, send the first text. The other four run in a background tail, after the user is already in a conversation. Signup latency is one provision plus one model call, and a failure in the tail costs a feature rather than the account.
Two conversations with the same agent
Chatting is agents.chat on a stable conversationId. The nightly correction pass runs on a second conversationId with a coach prompt that returns strict JSON, which becomes the Lessons tab. Same agent, same memory of the same week, two framings: one the user reads as a friend, one the app parses as a grader. A multiplexed design gets this by swapping system prompts around a shared store. Here it falls out of the agent keeping its own history.
Three guards sit around that call, and all three generalize:
The clock lives outside the agent
Every framework ships internal scheduling, and a schedule that lives inside a process dies with it. These processes are asleep most of the time by design, so penpal keeps the clock in the app: a tick every ten minutes that decides who is due for a check-in and whose nightly recap is ready.
Two details make that tick safe. It takes a Postgres advisory lock, so overlapping containers during a deploy cannot run it twice. And it claims each check-in with a conditional UPDATE before the model call, not after:
UPDATE users SET last_proactive_at = now()
WHERE id = $1
AND COALESCE(GREATEST(last_user_msg_at, last_proactive_at),
'epoch'::timestamptz)
<= now() - ($2 || ' hours')::interval
RETURNING idZero rows returned means someone else already has it, or the user is not due. Claiming first is what makes a double tick harmless and makes a failed send wait for the next cadence window instead of retrying every ten minutes forever. The work then runs through a pool of twelve, because one slow agent must not hold up everyone behind it.
The same tick repairs. Users whose provisioning failed are looked up by externalId, adopted if the agent turned out to exist, restarted if it errored, and sent the welcome text they never got. Most of what looks like a failed signup is a lost response to a call that actually worked.
Giving the agent write access to your product
Agents get more useful when they can change things, and that is exactly where credentials leak. The friend can adjust how often it texts, its correction style, or the level it thinks you are at, because users ask for those things in conversation rather than in a settings page.
It does not hold penpal's API key. It holds an HMAC token derived from one user id, injected into its environment, verified with a timing-safe compare, and accepted by exactly two endpoints: one that patches that user's settings against a hard allowlist of fields, one that mints a single-use dashboard link valid for an hour, since over SMS there is no session to hand over. Both are rate limited per user. Phone numbers and account fields are unreachable by construction, not by instruction.
That is the general shape. Not "here is our API", but a credential that names one row, and a surface that accepts only the columns you are willing to lose.
What it costs
Each signup provisions an agent at $1 per month plus compute while awake. Between messages the agent is a snapshot on disk with no process and no memory resident, and a wake is a 674 ms median restore, so a friendship that exchanges a few dozen texts a day spends most of the day costing nothing but the flat per-agent fee.
What you give up
The takeaway
Per-customer agents are not exotic anymore. The architecture holds up when an agent costs nearly nothing while idle, provisioning is idempotent, the clock lives outside the process, and the credential you hand the agent names exactly one user.
penpal is live at textpenpal.com, and the API behind it is documented here. One developer, nine days, one agent per user.
