Skip to content

Persona agents

Sub-agents covered the worker archetype: a one-shot you spawn, delegate to, and tear down. A persona is the other archetype — a long-lived assistant (a personal aide, a support agent, an on-call operator) that has to persist: an identity that survives restarts, a memory it updates over weeks, and the ability to act without being prompted. fromDir + the memory builtin + startAgent add exactly those three things — and nothing else. They ride the same shipped seams sub-agents do (the composed soul is just a system prompt, memory is a plain Tool, the heartbeat is post+wake on the runtime clock), so there is no new runtime.

Three surfaces, one runtime. Pick by lifetime and who drives the loop.

Your situation Surface Why
“Summarize these 40 PRs.” A bounded job that starts, produces a result, and ends. A coding sub-agent, a research fan-out. agent() A one-shot worker. No identity to persist, no clock — spawn, delegate, roll up tokens, done.
“Ava, my assistant — remembers my preferences, reminds me about the 3pm sync, runs for months.” A personal assistant / support persona defined by a folder you can ls and git. fromDir (this page) The directory IS the agent: bootstrap files compose the identity, a memory tool edits it, startAgent gives it a heartbeat.
“A service with my own inbound queue, my own scheduler, my own supervision tree.” You already own the loop and just want the trigger→turn seam. raw six verbs spawn/post/wake/wait/interrupt/close — drive the runtime directly; fromDir and startAgent are thin sugar over exactly these.

Point fromDir at a folder. These files, in this order, are each injected (when present) into the agent’s soul (system prompt) as a ## <filename> section — identity first, durable memory last:

# file role
1 AGENTS.md operating instructions
2 SOUL.md identity / voice
3 IDENTITY.md who the agent is
4 USER.md model of the user
5 TOOLS.md tool guidance
6 HEARTBEAT.md what to do on a heartbeat
7 MEMORY.md durable long-term notes

Absent files are skipped. Each is read with a 2 MB cap (measured in bytes — 2097152); larger files are injected truncated with a notice, the on-disk file untouched. Composition happens once, at session start — the soul is a frozen snapshot for the run, which is exactly the cache-stability rule, for free.

import { agents } from "toolnexus"
const ava = agents.fromDir("./personas/ava") // reads SOUL/USER/HEARTBEAT/MEMORY.md, wires `memory`
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)

fromDir also wires a memory tool over the same directory (opt out with memory: false for a read-only persona). One tool, three actions, two targets:

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

target = self (MEMORY.md, default) or user (USER.md). Every action writes to disk. A replace/remove whose substring is absent is a loud isError, never a silent no-op.

You rarely construct memoryTool directly (that’s what fromDir does), but it is public — add it to any agent’s uses.tools to give a non-persona agent a scratchpad:

const notes = agents.memoryTool("./personas/ava") // add | replace | remove over MEMORY.md / USER.md

A worker acts when you call it. A persona needs to act when nothing calls it — to notice the 3pm sync is due, to run a nightly cleanup. startAgent gives it its own clock: every everyMs it posts a tick to the agent’s own inbox (the unsolicited rail — ticks coalesce, so a slow beat can’t pile up) and, when idle, wakes it with a prompt to read HEARTBEAT.md and act, else reply HEARTBEAT_OK. A HEARTBEAT_OK reply is silent — no-op beats never message anyone; only a substantive reply reaches your onBeat callback. All timing runs on the runtime’s injectable clock (so fixtures run deterministically on a virtual clock).

const started = agents.startAgent(ava,
{ llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" } },
{ everyMs: 60_000, onBeat: (text) => sendToTelegram(text) }, // only NON-silent beats arrive here
)
// ... later:
await started.stop()

Staying under the context window — compaction

Section titled “Staying under the context window — compaction”

A heartbeat and durable memory keep a persona running — but every turn it also grows its transcript, and eventually that transcript overflows the model’s window and the provider errors. That is the classic persona killer: an assistant that “dies within days.” Compaction is the fix. compactor(opts) returns a beforeLLM hook: once the transcript estimate crosses maxTokens, it summarizes the older body into one system message and keeps a recent tail verbatim. It rides the existing beforeLLM seam — the loop applies a beforeLLM rewrite by replacing the working transcript — so it is a pure messages → messages helper and adds no loop behavior. Compaction is composition over a shipped seam, not a new subsystem.

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 (e.g. a cheap model) — the library never makes a model call on your behalf
countTokens(messages) → number token estimate; default ceil(chars/4) summed over messages (an estimator, not a tokenizer — override for exactness)
flushToMemory inject a pre-compact reminder to persist durable facts via the memory tool before the head is summarized (off by default)

Build a compactor and hand it to the client’s beforeLLM hook. Nothing else in your loop changes.

import { createClient, agents } from "toolnexus"
const compact = agents.compactor({
maxTokens: 120_000, // compact once the estimate crosses this
keepTail: 40_000, // always keep this much recent context verbatim
summarize: async (older) => summariseWithCheapModel(older), // MAY call a small model
})
const agent = createClient({
baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini",
hooks: { beforeLLM: compact }, // the one seam compaction rides
})

The snippets above build a client yourself. A persona usually runs on the agent runtime instead — and the runtime builds that client for you, so you hand it the hook rather than the options. Both the runtime and an individual agent accept hooks (and onMetric). An agent’s own value replaces the runtime-wide one — never merges — which is what lets two agents in one runtime carry different compaction budgets.

const researcher = agents.agent("researcher", {
does: "digs through long documents",
soul: "You are a careful researcher.",
hooks: { beforeLLM: agents.compactor({ maxTokens: 60_000, summarize }) },
})

Set the same field on the runtime instead and every agent gets it. Set both and the agent wins for that agent only; the two fields resolve independently, so an agent may override hooks and still inherit the runtime’s onMetric.

This is the headline — the three persona features composing into one durable agent. A summary loses detail, so on its own compaction slowly forgets. The fix is to pair it with memory: flushToMemory nudges the model to persist anything worth keeping to MEMORY.md before the older turns are summarized away, and that note reloads into the soul at the start of the next session. So the agent stays under budget (compactor) while its durable facts survive (the memory builtin) — a persona that runs for weeks without either overflowing or amnesia.

Because compaction lives on the client loop’s beforeLLM seam, the persona here runs through that loop directly: its identity is just the composed soul (composeSoul), and its memory is just the memory tool (memoryTool) over the same directory — both already public from the sections above. No new API; you are wiring three shipped pieces together.

import { createToolkit, createClient, agents } from "toolnexus"
const dir = "./personas/ava"
const { soul } = agents.composeSoul(dir) // identity + durable MEMORY.md, frozen for the session
const tk = await createToolkit() // shell/file built-ins …
tk.register(agents.memoryTool(dir)) // … plus the memory builtin
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, // save durable facts to MEMORY.md BEFORE summarizing
summarize: async (older) => summariseWithCheapModel(older),
}),
},
})
// Weeks of turns keyed by one stable id: the transcript self-compacts, MEMORY.md keeps growing.
await ava.ask(userText, { toolkit: tk, id: "ava" })

Both patterns below are plain if/loop over the primitives above. There is no consolidate() or onMessage() API to learn — that is the design: composability is userland, the library ships the seams.

1. Dream / consolidation — a persona that tidies its own memory

Section titled “1. Dream / consolidation — a persona that tidies its own memory”

Give the persona a HEARTBEAT.md that tells it to fold duplicate notes into MEMORY.md, then run it on a slow heartbeat (nightly). Each beat, it reads its own memory through the frozen snapshot and rewrites it with the memory tool — the write lands on disk and the next beat sees the tidied version. This is the whole “dream” loop: no scheduler, no new surface.

personas/ava/HEARTBEAT.md
On a heartbeat, review your MEMORY.md section:
- If two entries say the same thing, use the memory tool (action=replace) to merge them
into one clear entry.
- If an entry is stale or contradicted by a newer one, remove it.
- If nothing needs tidying, reply exactly HEARTBEAT_OK so this beat stays silent.
nightly-consolidation.ts
import { agents } from "toolnexus"
const ava = agents.fromDir("./personas/ava")
// One tick every 24h. A tidy-up beat returns a short summary; a quiet night returns
// HEARTBEAT_OK and surfaces nothing.
const started = agents.startAgent(ava,
{ llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" } },
{ everyMs: 24 * 60 * 60_000, onBeat: (summary) => console.log("consolidated:", summary) },
)

2. Channel-driven assistant — inbound message → the persona acts

Section titled “2. Channel-driven assistant — inbound message → the persona acts”

The persona should also act when a real message arrives. Your channel host (a Telegram webhook, a WhatsApp handler — messenger’s job) turns each inbound message into a post + wake on the running persona’s handle, and routes the reply back out. The library owns the trigger seam; you own the transport.

channel-assistant.ts
const started = agents.startAgent(ava,
{ llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" } },
{ everyMs: 5 * 60_000 }, // heartbeat still runs alongside inbound traffic
)
const handle = started.runtime.spawn(started.runtime.root, ava.name)
// Your inbound webhook (Telegram/WhatsApp/Slack — the host's transport, not the library's):
async function onInboundMessage(from: string, text: string) {
started.runtime.post(handle, { from, channel: "telegram", text }) // land it in the inbox
const woke = started.runtime.wake(handle, "You have a new message — read it and respond.")
if (woke.ok) {
const r = await started.runtime.wait(handle)
await sendToTelegram(from, r.text) // route the reply back out
}
}

The same shapes hold in every port (the verbs are post/wake/wait on started.runtime); only the agent() spelling changes, as in the tabs above.

A real “Ava” assistant lives at examples/persona-agent/ava/ (a soul, a user model, a heartbeat rule, a seed memory). The JS / Python / Go entrypoints copy it to a writable temp dir, then prove the persona loop end-to-end against OpenRouter: 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.

  • Sub-agents & teams — the worker archetype and the six host verbs the heartbeat rides on.
  • Suspension & the human loop — how a persona parks on a human-in-the-loop request.
  • SPEC.md §7E — the full cross-language contract (bootstrap order, memory semantics, heartbeat).
  • SPEC.md §7F — context compaction: the beforeLLM contract, tool-pair rule, and no-op-below-budget guarantee.