Skip to content

Build an openclaw-style personal assistant

The scenario. You want the thing people mean when they say “a personal AI”: an assistant called Ava that already knows who you are when you say “hi”, remembers that you take your 1:1s on Tuesdays without being told twice, notices at 2:55pm that your 3pm sync is due and pings you on Telegram before you ask, and is still the same assistant six weeks later — not a chat window that forgot you on refresh.

That is three capabilities a plain chat loop does not have:

Capability What it means concretely What ships it
Identity that survives restarts Ava’s personality, your profile, and her operating rules live in files you can ls, git diff, and code-review — not in a prompt string in main.ts. fromDir
Memory she writes herself She learns “Muthu prefers dark roast” during a conversation and still knows it next Tuesday. the memory builtin
Acting unprompted A clock beat wakes her; she decides whether there is anything worth saying, and stays silent if not. startAgent heartbeat

Plus one that decides whether she survives past week one: compaction, so a months-long transcript never blows the context window.

Step 1 — the home directory is the agent

Section titled “Step 1 — the home directory is the agent”

Ava is a folder. Everything below is real content from examples/persona-agent/ava/, the fixture every port runs against — not placeholders.

personas/ava/
├── SOUL.md # who she is, how she speaks
├── USER.md # who YOU are (she edits this)
├── HEARTBEAT.md # what to do when nothing asked her anything
└── MEMORY.md # what she has learned (she edits this)

fromDir injects each present file into the system prompt as a ## <filename> section, in this fixed order — identity first, durable memory last:

# file role who writes it
1 AGENTS.md operating instructions you
2 SOUL.md identity / voice you
3 IDENTITY.md who the agent is you
4 USER.md model of the user you, then the agent (target: "user")
5 TOOLS.md tool guidance you
6 HEARTBEAT.md what to do on a beat you
7 MEMORY.md durable long-term notes the agent (target: "self")

Absent files are skipped. Each is read with a 2 MB cap (bytes — 2097152); larger files are injected truncated with a notice and the file on disk is untouched.

This is the one file that decides whether the assistant feels like an assistant or like a chatbot. Be specific about voice, and — critically — state the memory policy here, because the model decides when to call the memory tool and it needs a rule.

personas/ava/SOUL.md
You are **Ava**, a calm, precise personal assistant.
Voice: warm but economical — a sentence or two, never a wall of text. You never
invent facts about the user; when you are unsure you say so and ask one specific
question. You prefer doing the small thing now over promising the big thing later.
You have a durable memory. When you learn a *stable* fact about the user (a
preference, a recurring commitment, a name), record it with the `memory` tool so
your next session starts already knowing it. You do **not** record one-off chatter.

Seed it by hand with what you already know. Ava extends it herself via the memory tool with target: "user".

personas/ava/USER.md
- Name: Muthu
- Timezone: IST (Asia/Kolkata)
- Prefers concise, direct answers
- Has a recurring 3pm daily sync

HEARTBEAT.md — what to do when nobody asked

Section titled “HEARTBEAT.md — what to do when nobody asked”

The most under-written file in most builds, and the one that decides whether your assistant is useful or a notification spammer. Write it as a decision procedure ending in silence.

personas/ava/HEARTBEAT.md
On a heartbeat, check whether anything is due for the user right now.
- If it is within a few minutes of 3pm IST, surface a short reminder about the 3pm
daily sync.
- Otherwise, there is nothing to do — reply with exactly `HEARTBEAT_OK` and nothing
else, so the beat stays silent.
Never chat on a heartbeat. Only speak when there is something the user needs.

Seed it with a single line and let it grow. It is a normal markdown file; you can edit it, and so can she.

personas/ava/MEMORY.md
# Ava's long-term memory
- Onboarded Muthu in 2026-07.

One call reads the folder, composes the soul, and wires the memory tool over the same directory. What you get back is a plain agentrun, asTool, startAgent all apply.

import { agents } from "toolnexus"
const ava = agents.fromDir("./personas/ava")
const r = await ava.run("What's on my plate today?", {
llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" },
})
console.log(r.status, r.text)

Options worth knowing on fromDir: name (defaults to the directory basename), does (the routing description if another agent delegates to her), model (defaults to "inherit"), tools (anything beyond the memory builtin — your calendar MCP server, your shell tools), and memory: false for a deliberately read-only persona.

Step 3 — how she learns: the memory builtin

Section titled “Step 3 — how she learns: the memory builtin”

fromDir wires one tool, memory, with three actions and two targets:

action effect
add append an entry
replace swap an existing substring (with)
remove delete an existing substring

target = self (MEMORY.md, the default) or user (USER.md). Every action writes to disk immediately. A replace/remove whose substring is absent is a loud isError — never a silent no-op, so a drifting memory edit surfaces instead of vanishing.

The frozen-snapshot rule (read this before you design around it)

Section titled “The frozen-snapshot rule (read this before you design around it)”

The soul is composed once, at session start, and does not change for the rest of the run. So when Ava calls memory add mid-conversation:

  1. the entry lands in MEMORY.md on disk, right away;
  2. the live system prompt is not modified;
  3. the entry becomes part of her identity at the start of the next session.

How to design around it, three practical rules:

  • Don’t ask her to write-then-read in one turn. If the model needs a fact now, it is in the conversation already — that is what the transcript is for. Memory is for next time.
  • Make sessions the natural unit. One session per conversation / per channel thread / per day is the sweet spot. A short-session host gets memory that feels near-instant; a never-ending single session gets memory that feels a day late.
  • Force a refresh when it matters. Nothing stops you from calling fromDir again to build a fresh persona instance — the new snapshot picks up every write since. That is a cheap, explicit “reload identity”, and it is exactly what the runnable example does to prove memory persisted (session 1 writes, session 2’s fresh fromDir recalls).

Step 4 — acting unprompted: the heartbeat

Section titled “Step 4 — acting unprompted: the heartbeat”

startAgent gives Ava her own clock. Each everyMs it posts a tick to her own inbox (the unsolicited rail — ticks coalesce, so a slow beat can never pile up into a backlog of turns) and, only when idle, wakes her with a prompt to read HEARTBEAT.md and act, else reply HEARTBEAT_OK.

A HEARTBEAT_OK reply is silent. It never reaches your callback, never reaches a channel, never becomes a notification. That single rule is what separates a useful ambient assistant from one you mute in a week: the default is silence, and speaking is the exception the model has to justify.

const llm = { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" }
const started = agents.startAgent(ava, { llm }, {
everyMs: 5 * 60_000, // check in every 5 minutes
onBeat: (text) => sendToTelegram(text), // ONLY non-silent beats arrive here
})
// ... on shutdown:
await started.stop()

Step 5 — channels: where the library stops and your host starts

Section titled “Step 5 — channels: where the library stops and your host starts”

This is the honest boundary, and it is deliberate. startAgent owns exactly one seam: trigger → turn. It is not a scheduler daemon, not a gateway, and ships no channel adapters — no Telegram, no WhatsApp, no Slack, no email. Your host owns transport.

The contract you implement is small: on an inbound message, post it into the running persona’s inbox, wake her, wait for the result, and send the reply back out on your transport.

// startAgent exposes the live runtime; spawn the handle your channel talks to.
const rt = started.runtime
const handle = rt.spawn(rt.root, ava.name)
// Your webhook / socket handler — Telegram, WhatsApp, Slack, SMS, email: your transport.
export async function onInboundMessage(from: string, channel: string, text: string) {
rt.post(handle, { from, channel, text }) // land it on the unsolicited rail
const woke = rt.wake(handle, "You have a new message — read it and respond.")
if (!woke.ok) return // busy: the inbox keeps it, next wake drains it
const r = await rt.wait(handle)
if (!r.isError) await sendToChannel(channel, from, r.text) // route the reply back out
}

Step 6 — surviving week two: compaction + flushToMemory

Section titled “Step 6 — surviving week two: compaction + flushToMemory”

Everything above gets you an assistant that works on day one. What kills it on day twenty is the transcript: every turn appends, and eventually the provider rejects the request. Compaction is the fix, and it is a pure messages → messages helper riding the existing beforeLLM hook — no new loop behavior.

option meaning
maxTokens compact only when the estimate exceeds this; at/below ⇒ no-op, byte-identical to no compactor
keepTail keep at least this many tokens of the most recent tail verbatim (default maxTokens/2)
summarize(older) → string produces the summary; MAY call an LLM (use a cheap model) — the library never calls a model on your behalf
countTokens(messages) → number estimate; default ceil(chars/4) — an estimator, not a tokenizer
flushToMemory inject a pre-compact reminder to persist durable facts via the memory tool before the head is summarized

flushToMemory is the hinge for a persona. Summarizing is lossy — on its own, compaction means slow forgetting. With flushToMemory, the model is nudged to write anything durable to MEMORY.md first, and that note reloads into the soul next session. Under budget and durable.

Two invariants the ports never break: the retained tail always begins at a user turn (so a tool message is never orphaned from the assistant carrying its tool_call_id), and a leading system message (the soul) is preserved verbatim — only the body between it and the tail is summarized. That second one is also what keeps the prompt cache valid.

Everything above, wired as one long-lived assistant. Because compaction lives on the client loop’s beforeLLM seam, this build runs the persona through the client directly: her identity is the composed soul (composeSoul), her memory is the memory tool (memoryTool) over the same directory, and a stable conversation id carries the transcript across turns.

import { createToolkit, createClient, agents } from "toolnexus"
const dir = "./personas/ava"
// 1. Identity — a frozen snapshot of the bootstrap files, composed once per session.
const { soul } = agents.composeSoul(dir)
// 2. Tools — the built-ins, her memory, and whatever else she needs (MCP, your own).
const tk = await createToolkit({ mcpConfig: "./mcp.json" }) // calendar, email, …
tk.register(agents.memoryTool(dir))
// 3. The loop — soul as system prompt, compaction with a memory flush on the beforeLLM seam.
const ava = createClient({
baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini",
systemPrompt: soul,
hooks: {
beforeLLM: agents.compactor({
maxTokens: 120_000,
keepTail: 40_000,
flushToMemory: true, // persist durable facts BEFORE summarizing
summarize: async (older) => summariseWithCheapModel(older),
}),
},
})
// 4. Inbound — your transport calls this; one stable id = one continuous assistant.
export async function onInboundMessage(from: string, text: string) {
const r = await ava.ask(text, { toolkit: tk, id: `ava:${from}` })
await sendToTelegram(from, r.text)
}

Being precise here is the point; it is what lets you plan the rest of your build.

toolnexus ships you bring
The identity format — bootstrap order, byte cap, frozen-snapshot composition The content of SOUL.md / USER.md / HEARTBEAT.md — the actual personality and policy
The memory tool — three actions, two targets, disk-backed, loud on a missed substring The policy for what is worth remembering (prose in SOUL.md)
The trigger→turn seampost / wake / wait, inbox coalescing, HEARTBEAT_OK silence The transport: Telegram/WhatsApp/Slack/email adapters, webhooks, auth, delivery retries
An in-process interval heartbeat on an injectable clock A scheduler daemon, cron, process supervision, restart-on-crash, “what runs when the box reboots”
Compaction primitives (maxTokens, keepTail, flushToMemory, tool-pair safety) The summarizer — the compactor calls your summarize; the library never calls a model for you
Suspension so the persona can park on a human decision The approval UI / the channel round-trip that answers it

That split is deliberate: a library that shipped a Telegram adapter and a cron daemon would be a product, and you would fight it the first time your infrastructure disagreed with it. The seam is post/wake precisely so a messenger-style host (one process owning channels, sessions and delivery, calling into the runtime) sits cleanly on top — and so does an AWS Lambda, a Phoenix app, or a systemd unit.

The real Ava — soul, user model, heartbeat rule, seed memory — lives at examples/persona-agent/ava/. The runnable entrypoints copy it to a writable temp dir and prove the loop end to end: session 1 saves a preference with the memory tool (it lands on disk), session 2’s fresh fromDir recalls it from the reloaded snapshot.

Terminal window
# JS
OPENROUTER_API_KEY=... node --experimental-strip-types js/examples/persona.ts
# Python
OPENROUTER_API_KEY=... python python/examples/persona.py
# Go
OPENROUTER_API_KEY=... go run ./golang/examples/persona

The key is read from the environment by name and never printed.