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.
When to use which surface
Section titled “When to use which surface”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. |
The directory is the agent — fromDir
Section titled “The directory is the agent — fromDir”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)from toolnexus.agents import agent_from_dir
ava = agent_from_dir("./personas/ava")r = await ava.run( "What's on my plate today?", llm={"base_url": "https://openrouter.ai/api/v1", "style": "openai", "model": "openai/gpt-4o-mini"},)print(r.status, r.text)import "github.com/muthuishere/toolnexus/golang/agents"
ava := agents.FromDir("./personas/ava", agents.FromDirOptions{})r, _ := ava.Run(agents.Options{LLM: &agents.LLMOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, Model: "openai/gpt-4o-mini",}}, "What's on my plate today?")fmt.Println(r.Status, r.Text)import io.github.muthuishere.toolnexus.agents.Agents;
Agents.Agent ava = Agents.agentFromDir(Path.of("personas/ava"), new Agents.AgentSpec());TaskResult r = ava.run(new RuntimeOptions() .baseUrl("https://openrouter.ai/api/v1").style("openai").defaultModel("openai/gpt-4o-mini"), "What's on my plate today?");System.out.println(r.status() + " " + r.text());using Toolnexus.Agents;
var ava = Home.FromDir("./personas/ava");var r = await ava.RunAsync(new RuntimeOptions{ BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "openai/gpt-4o-mini",}, "What's on my plate today?");Console.WriteLine($"{r.Status} {r.Text}");alias Toolnexus.Agents.Home
ava = Home.from_dir("./personas/ava")r = Toolnexus.Agents.run(ava, [llm: %{base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini"}], "What's on my plate today?")IO.puts("#{r.status} #{r.text}")Durable memory — the memory builtin
Section titled “Durable memory — the memory builtin”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.mdnotes = memory_tool("./personas/ava")notes := agents.MemoryTool("./personas/ava")Tool notes = Agents.memoryTool(Path.of("personas/ava"));var notes = Home.MemoryTool("./personas/ava");notes = Home.memory_tool("./personas/ava")The heartbeat — startAgent
Section titled “The heartbeat — startAgent”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()started = start_agent( ava, llm={"base_url": "https://openrouter.ai/api/v1", "style": "openai", "model": "openai/gpt-4o-mini"}, every_ms=60_000, on_report=lambda text: send_to_telegram(text), # only NON-silent beats arrive here)# ... later:await started.stop()started, _ := agents.StartAgent(ava, agents.StartOptions{ Options: agents.Options{LLM: llm}, EveryMs: 60_000, OnBeat: func(text string) { sendToTelegram(text) }, // only NON-silent beats arrive here})// ... later:started.Stop()Agents.StartedAgent started = Agents.startAgent(ava, rtOpts, 60_000L, text -> sendToTelegram(text)); // only NON-silent beats arrive here// ... later:started.stop();var started = Home.StartAgent(ava, rtOpts, everyMs: 60_000, onBeat: text => SendToTelegram(text)); // only NON-silent beats arrive here// ... later:await started.StopAsync();started = Home.start_agent(ava, rt_opts, every_ms: 60_000, on_beat: fn text -> send_to_telegram(text) end) # only NON-silent beats arrive here# ... later:Home.stop(started)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) |
Wire it as a beforeLLM hook
Section titled “Wire it as a beforeLLM hook”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})from toolnexus import create_client, compactor
compact = compactor( max_tokens=120_000, keep_tail=40_000, summarize=lambda older: summarise_with_cheap_model(older), # MAY call a small model)
agent = create_client( base_url="https://openrouter.ai/api/v1", style="openai", model="openai/gpt-4o-mini", hooks={"before_llm": compact}, # the one seam compaction rides)compact := agents.Compactor(agents.CompactorOptions{ MaxTokens: 120_000, KeepTail: 40_000, Summarize: func(older []any) (string, error) { return summariseWithCheapModel(older) },})
agent := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, Model: "openai/gpt-4o-mini", Hooks: &toolnexus.Hooks{BeforeLLM: compact}, // the one seam compaction rides})import io.github.muthuishere.toolnexus.Compaction;
LlmClient.Hooks hooks = new LlmClient.Hooks();hooks.beforeLLM = Compaction.compactor(new Compaction.Options() .maxTokens(120_000).keepTail(40_000) .summarize(older -> summariseWithCheapModel(older))); // MAY call a small model
LlmClient agent = LlmClient.create(new LlmClient.Options() .baseUrl("https://openrouter.ai/api/v1").style("openai").model("openai/gpt-4o-mini") .hooks(hooks));using Toolnexus.Agents;
var hooks = new LlmClient.Hooks { BeforeLLM = Compaction.Compactor(new Compaction.Options { MaxTokens = 120_000, KeepTail = 40_000, Summarize = older => SummariseWithCheapModel(older), // MAY call a small model }),};
var agent = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "openai/gpt-4o-mini", Hooks = hooks,});compact = Toolnexus.Agents.Compaction.compactor( max_tokens: 120_000, keep_tail: 40_000, summarize: fn older -> summarise_with_cheap_model(older) end) # MAY call a small model
client = Toolnexus.Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", hooks: %{before_llm: compact})Wire it on an agent run (per agent)
Section titled “Wire it on an agent run (per agent)”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 }) },})researcher = agent( "researcher", does="digs through long documents", soul="You are a careful researcher.", hooks={"before_llm": compactor(max_tokens=60_000, summarize=summarize)},)researcher := agents.New("researcher", agents.Spec{ Does: "digs through long documents", Soul: "You are a careful researcher.", Hooks: &toolnexus.Hooks{BeforeLLM: agents.Compactor(agents.CompactorOptions{ MaxTokens: 60000, Summarize: summarize, })},})var hooks = new LlmClient.Hooks();hooks.beforeLLM = Compaction.compactor(new Compaction.Options() .maxTokens(60_000).summarize(summarize));
var researcher = Agents.agent("researcher") .does("digs through long documents") .soul("You are a careful researcher.") .hooks(hooks);var researcher = Agent.Create("researcher", new AgentSpec { Does = "digs through long documents", Soul = "You are a careful researcher.", Hooks = new LlmClient.Hooks { BeforeLLM = Compaction.Compactor(new Compaction.Options { MaxTokens = 60_000, Summarize = summarize, }), },});researcher = Toolnexus.Agents.agent("researcher", does: "digs through long documents", soul: "You are a careful researcher.", hooks: %{ before_llm: Toolnexus.Agents.Compaction.compactor(max_tokens: 60_000, summarize: 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.
Recipe: keep a persona alive for weeks
Section titled “Recipe: keep a persona alive for weeks”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 sessionconst 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" })from toolnexus import create_toolkit, create_client, compactor, compose_soul, memory_tool
dir = "./personas/ava"soul, _found = compose_soul(dir) # identity + durable MEMORY.md, frozen for the sessiontk = await create_toolkit() # shell/file built-ins …tk.register(memory_tool(dir)) # … plus the memory builtin
ava = create_client( base_url="https://openrouter.ai/api/v1", style="openai", model="openai/gpt-4o-mini", system_prompt=soul, hooks={"before_llm": compactor( max_tokens=120_000, keep_tail=40_000, flush_to_memory=True, # save durable facts to MEMORY.md BEFORE summarizing summarize=lambda older: summarise_with_cheap_model(older), )},)
await ava.ask(user_text, tk, "ava") # weeks of turns; transcript self-compactsdir := "./personas/ava"soul, _ := agents.ComposeSoul(dir) // identity + durable MEMORY.md, frozen for the sessiontk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{}) // shell/file built-ins …tk.Register(agents.MemoryTool(dir)) // … plus the memory builtin
ava := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, Model: "openai/gpt-4o-mini", SystemPrompt: soul, Hooks: &toolnexus.Hooks{BeforeLLM: agents.Compactor(agents.CompactorOptions{ MaxTokens: 120_000, KeepTail: 40_000, FlushToMemory: true, // save durable facts to MEMORY.md BEFORE summarizing Summarize: func(older []any) (string, error) { return summariseWithCheapModel(older) }, })},})
ava.Ask(ctx, userText, tk, "ava") // weeks of turns; transcript self-compactsimport io.github.muthuishere.toolnexus.Compaction;import io.github.muthuishere.toolnexus.agents.Agents;
Path dir = Path.of("personas/ava");String soul = Agents.composeSoul(dir).soul(); // identity + durable MEMORY.md, frozen for the sessionToolkit tk = Toolkit.create(new Toolkit.Options()); // shell/file built-ins …tk.register(Agents.memoryTool(dir)); // … plus the memory builtin
LlmClient.Hooks hooks = new LlmClient.Hooks();hooks.beforeLLM = Compaction.compactor(new Compaction.Options() .maxTokens(120_000).keepTail(40_000) .flushToMemory(true) // save durable facts to MEMORY.md BEFORE summarizing .summarize(older -> summariseWithCheapModel(older)));
LlmClient ava = LlmClient.create(new LlmClient.Options() .baseUrl("https://openrouter.ai/api/v1").style("openai").model("openai/gpt-4o-mini") .systemPrompt(soul).hooks(hooks));
ava.ask(userText, tk, "ava"); // weeks of turns; transcript self-compactsusing Toolnexus.Agents;
var dir = "./personas/ava";var (soul, _) = Home.ComposeSoul(dir); // identity + durable MEMORY.md, frozen for the sessionawait using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); // shell/file built-ins …tk.Register(Home.MemoryTool(dir)); // … plus the memory builtin
var ava = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "openai/gpt-4o-mini", SystemPrompt = soul, Hooks = new LlmClient.Hooks { BeforeLLM = Compaction.Compactor(new Compaction.Options { MaxTokens = 120_000, KeepTail = 40_000, FlushToMemory = true, // save durable facts to MEMORY.md BEFORE summarizing Summarize = older => SummariseWithCheapModel(older), }), },});
await ava.AskAsync(userText, tk, "ava"); // weeks of turns; transcript self-compactsdir = "./personas/ava"{soul, _found} = Toolnexus.Agents.Home.compose_soul(dir) # identity + durable MEMORY.md, frozen for the session{:ok, tk} = Toolnexus.create_toolkit() # shell/file built-ins …tk = Toolnexus.Toolkit.register(tk, [Toolnexus.Agents.Home.memory_tool(dir)]) # … plus the memory builtin
ava = Toolnexus.Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", system_prompt: soul, hooks: %{before_llm: Toolnexus.Agents.Compaction.compactor( max_tokens: 120_000, keep_tail: 40_000, flush_to_memory: true, # save durable facts to MEMORY.md BEFORE summarizing summarize: fn older -> summarise_with_cheap_model(older) end)})
Toolnexus.Client.ask(ava, user_text, tk, "ava") # weeks of turns; transcript self-compactsRecipes — composition, not new API
Section titled “Recipes — composition, not new API”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.
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.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.
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.
Try it — the runnable example
Section titled “Try it — the runnable example”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.
# JSOPENROUTER_API_KEY=... node --experimental-strip-types js/examples/persona.ts# PythonOPENROUTER_API_KEY=... python python/examples/persona.py# GoOPENROUTER_API_KEY=... go run ./golang/examples/personaThe key is read from the environment by name and never printed.
See also
Section titled “See also”- 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
beforeLLMcontract, tool-pair rule, and no-op-below-budget guarantee.