All articles
Architecture

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.

Maritime Team·August 28, 2026·7 min read

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.

The product: a friend who texts in the language you are learning, running on a dedicated agent per user
The product: a friend who texts in the language you are learning, running on a dedicated agent per user

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.

One model, memory as rowsUser AUser BUser COne processuser id inthe promptmemoryrowsthe person is reassembled on every requestOne agent per userUser Aagent + volumeUser Bagent + volumeUser Cagent + volumethe person is what this machine already remembers
Two ways to give a user a persistent personality. Only one of them survives a restart without being rebuilt

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:

  • Retry the timeouts, surface the caps. Three attempts, ten seconds apart, but a concurrency error from the platform is thrown immediately rather than retried, because waiting cannot help and the user is owed an answer.
  • Treat leaked tool calls as errors. A reply that looks like a tool invocation instead of a sentence is never rendered and never texted. Agent output reaching a real phone deserves the same suspicion as user input reaching your database.
  • Timeout every outbound call. Maritime, Inkbox and exec calls all carry an AbortSignal deadline, so a hung upstream cannot hang a request handler or the scheduler.

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 id

Zero 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

  • A per-user cost floor. Multiplexing has a near-zero marginal cost for an idle user; this does not. Sleep makes the floor small, not zero. For a free tool with a million signups this is the wrong shape. When the relationship is the product, the floor is cheaper than the engineering you would otherwise write to fake it.
  • Concurrency is a real limit. Grandfathered wallet accounts cap how many agents may be awake at once, which is why penpal has a dedicated error type for the moment everyone wakes up together. Seat-billed accounts have no cap, but it is worth knowing before you model a launch spike.
  • Nothing is shared between agents. A phrasing that works well in one friendship does not propagate. Improvements ship through instructions and skills at provision or restart time, not by editing one prompt and watching everyone get better at once.
  • Per-friend phone numbers are not on yet. Each friend has its own mailbox, but numbers are a paid add-on penpal has not enabled, so all SMS currently rides one number and inbound texts are matched to a user by their verified phone. The per-friend path is written and waiting, which is a fair description of most infrastructure claims until someone turns them on.

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.