Personalization & Workspace
Hand-edited persona files, auto-rebuilt financial profile, 10 behavioural-tag dimensions, and rebuild flows
Personalization is the layer that gives the agent a stable understanding of who it is talking to: experience level, risk appetite, preferred assets, prior trading summary, and persona instructions for how to respond.
It has two distinct sub-layers:
- Workspace files (hand-edited markdown): agent persona, startup instructions, user profile, curated long-term memory. You control these with an editor.
- Financial profile + user tags (auto-rebuilt DB tables): a scheduled LLM pass reads your chat and trading history and maintains structured fields (trading summary, 10 tag dimensions, personalization-class memories).
Both feed the prompt builder. The file layer is stable and explicit, the DB layer reflects actual behavior and updates over time.
Why two layers instead of one? A pure file layer forces users to keep notes about themselves up to date by hand, and most won't. A pure inference layer drifts — the LLM's impression of "who this user is" shifts with every trade and can overwrite deliberate preferences. Splitting the two keeps user-authored intent (persona, explicit preferences) on top and treats the inferred profile as evidence the agent reads, not overrides. Same reason your résumé sits in one file and your trading track record sits in another: different authors, different refresh cycles.
See this in use: Features → Memory covers the user-facing "tell Minara about me" flow that populates both layers.
For per-role decision reflection (a different kind of memory entirely) see Role Memory.
Workspace files (the persona layer)
Minara reads a small set of markdown files at session start to
customize identity, user profile, and curated long-term memory.
The directory is compatible with OpenClaw's layout so you can point
Minara at an existing OpenClaw workspace via --workspace.
Layout
Default location: ~/.minara/workspace/.
workspace/
├── SOUL.md — agent identity / persona
├── AGENTS.md — session startup instructions
├── IDENTITY.md — agent self-description (name, vibe, emoji)
├── USER.md — profile of the user being helped
├── MEMORY.md — curated long-term memory
├── TOOLS.md — tool reference (informational)
├── BOOTSTRAP.md — first-run only, deleted after initial read
├── HEARTBEAT.md — session state file
└── memory/
└── YYYY-MM-DD.md — daily memory notes (3-day window)minara setup creates the directory and populates sensible defaults.
Edit with any editor; writes take effect on the next session
because the workspace is loaded once at boot and frozen for the
session lifetime.
File inventory
| File | Purpose | Prompt placement |
|---|---|---|
SOUL.md | Agent identity / persona | Cacheable identity block (prefix) |
AGENTS.md | Session startup instructions | Cacheable identity block (prefix) |
USER.md | Profile of the user being helped | Personalization snapshot (dynamic) |
MEMORY.md | Curated long-term memory | Memory snapshot block (dynamic) |
memory/YYYY-MM-DD.md | Daily memory notes (most recent 3 days) | Memory snapshot block (dynamic) |
IDENTITY.md | Agent self-description (name, vibe, emoji) | Metadata only (display, never prompt) |
TOOLS.md | Human-readable tool reference | Informational; agent sees schemas instead |
BOOTSTRAP.md | First-run-only setup instructions | Merged into identity for one turn |
HEARTBEAT.md | Session state file | See Workflows and Autopilot |
How each file lands in the prompt
SOUL.md ──┐
├─▶ systemPromptPrefix (cached block)
AGENTS.md ──┘
MEMORY.md ──┐
memory/* ──┼─▶ memory snapshot block (dynamic)
USER.md ──┘
IDENTITY.md ───▶ metadata (name, emoji) for display only
TOOLS.md ───▶ informational, rarely injectedSOUL.mdandAGENTS.mdare cached alongside identity, so cache hit rates stay high. Keep edits small and test with/promptafterward — a structural break blows the cache for every session until the next edit.USER.mddescribes the user; appended to the personalization snapshot each turn.MEMORY.mdis curated long-term memory — prefer writing viamemory_writeduring a session and letting compaction promote durable facts to this file.memory/YYYY-MM-DD.mdfiles are daily notes; only the most recent 3 days load.IDENTITY.mdandTOOLS.mdnever enter the prompt.BOOTSTRAP.mdruns exactly once on first session and is deleted.
Frozen snapshot semantics
The workspace is loaded once at session boot. Mid-session writes persist to disk but do not modify the running prompt — the same frozen-snapshot pattern used for the memory store (see Memory System) and for the same reason: Anthropic prompt-cache stability.
If you edit USER.md mid-session, restart the REPL or run /new so
the snapshot reloads.
The /workspace REPL command
/workspace soul # print SOUL.md
/workspace agents # print AGENTS.md
/workspace user # print USER.mdSee Slash Commands → /workspace.
Workspace safety notes
- Workspace files are trusted input. Do not hand them to
untrusted users without review. A malicious
SOUL.mdcan set the persona to "always confirm trades without asking" and the LLM will obey. - Edits take effect on the next session. Mid-session edits to
USER.mddon't take effect until restart //new. SOUL.mdandAGENTS.mdare cached prompt blocks. Structural edits can blow cache hit rate across every session until the next edit — keep changes small and verify with/prompt.
Source: apps/agent/src/config/workspace.ts.
Financial profile & user tags (auto-rebuilt)
While the workspace files are hand-edited, the financial profile layer is rebuilt automatically by a scheduled LLM pass that reads recent conversation and trading history.
The two tables plus one category
Everything the personalization service maintains lives in three places:
financial_profile: one row per user. Trading summary, reference wallets, custom prompt fragment, visibility flags, rebuild cooldown cursors.user_tags: up to 10 rows per user, one per dimension, plus asourcefield that records whether the value was user-declared or inferred from behavior.- Personalization-class memories: ordinary rows in the
memoriestable undercategory = 'personalization'. They live alongside regular memories so FTS5 and the frozen-snapshot loader work without special cases, but they load with higher priority.
Everything is keyed by user_id (defaults to 'default' for
single-user deployments).
financial_profile schema
| Field | Type | Purpose | Prompt placement |
|---|---|---|---|
user_id | TEXT PK | Identifies the user. Defaults to 'default'. | — |
platform_wallet_summary | TEXT | LLM-generated summary of the user's Minara wallet activity. | Personalization block |
reference_wallets_summary | TEXT | LLM-generated summary of wallets the user follows as references. | Personalization block |
reference_wallets_json | TEXT | JSON array of reference wallet addresses. | Personalization block |
custom_prompt | TEXT | Freeform user-defined instructions appended to the system prompt. | Personalization block |
include_memories | INTEGER | Visibility flag. 0 hides personalization memories from the prompt. | Toggles block inclusion |
include_trading_summary | INTEGER | Visibility flag for the trading summary text. | Toggles block inclusion |
include_tags | INTEGER | Visibility flag for the behavioural-tag line. | Toggles block inclusion |
trading_summary_next_update | TEXT | Cooldown target: earliest time the summary can be rebuilt again. | — |
tags_next_update | TEXT | Cooldown target for the tags rebuild. | — |
memories_next_update | TEXT | Cooldown target for personalization-memory extraction. | — |
last_indexed_chat_id | TEXT | Cursor for incremental chat scanning in the memory rebuilder. | — |
trading_summary_updated_at | TEXT | When the trading summary itself was last rebuilt (distinct from row-level updated_at). | — |
created_at, updated_at | TEXT | Standard row timestamps. | — |
Schema lives in
apps/agent/src/memory/personalization-service.ts.
Missing columns on an existing database get added by idempotent
ALTER TABLE migrations at boot.
The 10 behavioural-tag dimensions
Every user gets a vector of finance-trait tags inferred from their
trading history and conversation. Each dimension stores a machine
value (a slug, or level_N for a graded scale); the human label
shown below is what the UI and the prompt render. Six dimensions are
the v2 / User Portrait set; the four personality dimensions carry
over from v1 unchanged (their value equals their label).
| Dimension | Allowed values (value → label) |
|---|---|
finance_knowledge | level_1 Beginner / level_2 Intermediate / level_3 Advanced / level_4 Expert |
frequency | passive / weekly / daily / active |
markets (multi-select) | crypto_majors / crypto_alts / memes / stocks / commodities / pre_ipo |
risk | conservative / balanced / aggressive |
web3_knowledge_level | level_1 Novice / level_2 Familiar / level_3 Experienced / level_4 Pro |
style | fundamentals / technical / narrative / news_event / community |
FOMO Index | Very Low / Low / Medium / High / Very High |
FUD Immunity | Strong / Medium / Weak |
Patience Level | High / Medium / Low |
Greed Index | Very Low / Low / Medium / High / Very High |
markets is multi-select: its value is a JSON array string in the
single user_tags.value column, every other dimension holds one
value. Schema lives in
apps/agent/src/memory/tags-schema.ts;
callers go through its typed helpers (allowedValues,
isValidTagValue, serializeTagValue, parseStoredTagValue,
renderTagSchema) rather than reading the schema map directly. Writes
outside these values are rejected by
PersonalizationService.upsertTag rather than silently coerced.
Beyond onboarding and conversation inference, markets has an objective
source. A 24-hour cron (MarketsObjectiveUpdater,
apps/agent/src/memory/markets-updater.ts)
scans the symbols of the user's current autopilot strategies plus the perps
fills recorded in the last 30 days, classifies each symbol into a market, and
push-unions the result into the tag through
PersonalizationService.unionMarkets. It only adds values, never removes,
and keeps the row's existing source, so an inferred push never downgrades
or clears a user-declared choice. Off-agent perps activity nudges an
earlier, cooldown-gated run.
Existing v1 rows migrate to this schema with the one-off
pnpm --filter @minara/agent migrate:user-tags-v2 script (dry-run by
default, --apply to write). It remaps Risk Profile → risk,
Web3 Knowledge Level → web3_knowledge_level,
Decision-Making Style → style, and Asset Preference →
markets, drops the retired Asset Tier / Trading Frequency /
Learning Preference dimensions, and leaves the four personality
dimensions untouched.
Capital metrics (objective, agent-internal)
A user's committed capital is an objective metric, never self-reported
and never shown in Settings. It replaces v1's self-reported Asset Tier
tag. It lives in its own capital_metrics table (one row per user) and
feeds agent reasoning only — it is not part of the user-facing
personalization snapshot.
capital_total_usd = spot_holdings_usd + perp_value_usdspot_holdings_usd sums the cross-chain portfolio asset values;
perp_value_usd is the aggregated perp sub-account equity. The total is
bucketed into 8 tiers (tier_1 < $10 … tier_8 ≥ $50k). Recompute is
driven by a 24-hour cron plus off-agent-activity nudges, gated by a
cooldown, and degrades to "keep the last value" when the read sources are
unavailable rather than writing a misleading zero. Schema + tiers live in
apps/agent/src/memory/capital-metrics.ts.
Strategy runs (autopilot history)
strategy_runs is an append-only history of autopilot activations — one row
per enable→disable cycle, with the allocated capital, start/stop times,
status, a stop_reason (user_manual / insufficient_balance /
drawdown_protection / liquidated / strategy_expired / other), and the
realized PnL backfilled on stop. Like capital, it is agent-internal, not part
of the user-facing snapshot.
Enabling a fully-managed strategy opens a run; disabling it through the agent
closes it as user_manual. Because fullyManagedStrategies lives upstream,
stops the agent never sees (a disable from the web UI, or an automatic stop
on drawdown / liquidation) are caught by a reconcile pass: whenever the agent
lists strategies, any open run no longer in the upstream running set is closed
(other), guarded by a short grace window so a just-enabled run isn't closed
before it shows as running. Store lives in
apps/agent/src/memory/strategy-runs.ts.
Manual trading profile
TradingProfileReader aggregates the user's off-agent perps activity (the
perps_fills mirror — trades made on web / mobile / by hand) over a 30-day
window into a compact picture: trade count, top symbols by count + volume,
long-vs-short split, realized PnL, win rate over closing fills, average trade
size, and last-traded time. It also backs the search_user_trades agent
tool, which returns a user's recent fills for one asset (side, open/close
direction, USD size, price, realized PnL). Both are agent-internal and ground
analysis in the user's real history. The mirror carries no leverage and no
manual-vs-autopilot attribution, so those are out of scope here. Source:
apps/agent/src/memory/trading-profile.ts.
On-demand personalization recall
The cache-safe way to make personalization intent-aware is to let the model
pull what it needs rather than always-injecting everything into the cached
prefix. search_conversation_memory
(conversation-memory-tool.ts)
recalls the user's durable personalization memories (preferences, profile
facts, constraints, goals) on demand, scoped to the personalization
category and reusing the FactLayer hybrid retrieval. Tool results land after
the cached prefix, so recall never perturbs the prompt cache. It pairs with
personalization_snapshot (the full portrait on demand) and memory_search
(all categories).
The always-injected block can also be split to keep the cached prefix lean.
CHAT_INTENT_GATING_ENABLED turns on intent gating: the stable, cached
prefix keeps the always-inject core (the trading summary, the core
finance_knowledge / web3_knowledge_level / risk tags, the most recent
memories, the custom prompt, and any active preferences), and a one-shot
classifier
(chat-intent.ts) picks
which of the remaining behavioural-tag dimensions this turn needs. Those
extras are appended to the per-turn volatile block, so they never perturb the
cached prefix; the classifier is instructed to select nothing for a pure
tool-lookup turn. With the flag off (the default), the full personalization
block is injected into the cached prefix every turn (the legacy behaviour).
The split ships behind the flag pending a live cache-hit measurement (see
Context Management).
Onboarding
A user-level onboarding flow seeds the portrait from explicit answers.
POST /v1/profile/onboarding maps the answers to user-sourced tags
(finance_knowledge, frequency, risk, markets), writes one
personalization memory per answered dimension (first completion only, so a
re-submit updates tags without duplicating memories), and records the
self-reported investing capital as a memory — never as a tag or capital
metric (capital stays objective). The raw answers + a completion flag persist
on the financial_profile row; GET /v1/profile/onboarding reports status so
the web UI knows whether to show the flow. The call is idempotent and rejects
invalid tag values before any write. Implemented on
PersonalizationService
(completeOnboarding / getOnboardingStatus).
Each row in user_tags also records a source field noting
whether the value was user-declared or LLM-inferred. The prompt
builder uses this to weight which tags to surface (declared
values beat inferred ones when both exist).
The three rebuild flows
Personalization is rebuilt periodically by
apps/agent/src/memory/personalization-rebuilder.ts.
The heartbeat monitor schedules three independent methods with
their own cooldown windows so one slow rebuild cannot block the
others.
| Method | Triggers | Cooldown (default) | Input | Output |
|---|---|---|---|---|
rebuildTradingSummary() | trade_history / perps_fills:recorded / external_spot:recorded events; force via /profile refresh | 30 minutes | Three sources merged: in-session trade_history, perps_fills (off-agent perps mirror), external_spot_activities (off-agent spot mirror), plus reference wallets | Composite platform_wallet_summary, reference_wallets_summary, trading_summary_updated_at |
rebuildTags() | Scheduled via tags_next_update | 30 days | Profile + trading history + enum schema | Tag rows upserted into user_tags |
rebuildMemories() | Scheduled via memories_next_update | 10 minutes | Chats created after last_indexed_chat_id | Personalization-class memories + cursor advance |
Each rebuild is a single cheap LLM call (Haiku by default). The cooldowns are tuned for v1 parity: trading summary refreshes often (new trades matter), tags rarely (they're slow-moving profile data), memory extraction frequently (catch new preferences as the user expresses them).
The last_indexed_chat_id cursor means rebuildMemories only
reads chats it hasn't seen before. Without it the rebuild would
re-read the full chat history on every fire and burn token budget.
Three-source trading summary in detail
rebuildTradingSummary() reads three independent sources, gates on a
combined threshold, and advances three independent cursors so a parse
failure on one rebuild never silently drops data from the others.
┌──────────────────────────────────────────┐
trade event ────►│ trade_history (in-session) │──┐
└──────────────────────────────────────────┘ │
│
┌──────────────────────────────────────────┐ │
Minara web/ ►│ perps_fills (cross-sub mirror) │──┤
mobile perps └──────────────────────────────────────────┘ │
(via ▼
MinaraHistorySync.syncAll) ┌─────────────────────────┐
┌──────────────────────────────────────────┐ │ rebuildTradingSummary │
Minara web/ ►│ external_spot_activities (mirror) │►┤ gates: newTrades + │
mobile spot └──────────────────────────────────────────┘ │ newPerpsFills + │
│ newExternalSpot ≥ │
│ threshold │
│ │
│ LLM emits 4 fields → │
│ platformWalletSummary │
│ spotBreakdown │
│ perpsBreakdown │
│ referenceWalletsSummary│
│ → composed into one │
│ platform_wallet_summary│
│ string with Spot: / │
│ Perps: prefixes │
└─────────────────────────┘Three independent cursors live on the financial_profile row:
trading_summary_last_trade_id_seen(existing)trading_summary_last_perps_fill_id_seen(new)trading_summary_last_external_spot_id_seen(new)
All three only advance after the LLM returns a parseable response and the new summary has been written. A parse failure leaves every cursor in place so the next rebuild attempts the same window — never silently losing a source.
MinaraHistorySync triggers
The off-agent mirror tables are filled by MinaraHistorySync
(apps/agent/src/memory/minara-history-sync.ts). The sync is fire-and-forget,
self-throttled, and only ever degrades to 0 rows added on failure.
Three trigger paths:
- Trade event piggyback —
eventBus.on("trade:recorded", () => minaraHistorySync.scheduleSync()). When the agent itself records a trade, the user is likely active on web/mobile too, so this is a cheap way to keep the mirror current. The 5-minute throttle (historySyncMinIntervalMs) collapses bursts. - Safety-net tick — every 30 minutes the app ticker calls
minaraHistorySync.runIfStale()so a missed event never permanently strands the mirror. - Forced refresh — CLI
/profile refresh(or its HTTP twinPOST /v1/profile/refresh) bypasses the throttle and runsrunOnce()before the rebuild, guaranteeing the next summary sees the freshest off-agent activity.
After historySyncMaxFailures (default 5) consecutive failures on
one (source, sub_account_id) row, the sync skips that key during
normal scheduling. Once historySyncFailureCooldownMs (default 30
minutes) elapses since last_synced_at, a probe runs anyway — a
successful probe resets consecutive_failures to 0. This keeps a
transient outage from permanently disabling the mirror.
Boundary with memory.trading-cases
memory.trading-cases (the methodology_cases SQLite table) is a
different memory that lives next to the personalization picture
without overlapping it. The split exists because the two records serve
different consumers and would corrupt each other if mixed:
memory.trading-casesis the agent's learning loop. Each row is one in-session decision the agent made, attributed to one or more methodology ids, with a Wilson-graduated outcome scored later. Its consumer is the methodology system, which uses it to decide whether a methodology should keep getting suggested.- The personalization picture (this page) is the agent's view of who the user is, summarized into a paragraph the agent always carries in the system prompt. It reads in-session trades + off-agent perps + off-agent spot.
External fills from MinaraHistorySync are deliberately never
written into methodology_cases — they don't carry methodology ids or
hint hashes, so back-attributing them would pollute the Wilson
statistics that gate methodology graduation. By the same token, the
trading-cases page in the web UI is a read-only audit dashboard —
edits would corrupt the learning corpus.
Relationship to ordinary memory_write
The personalization rebuilder is not routed through the audit log hook. That is deliberate:
- Rebuild runs on a schedule. Every fire would produce dozens of audit rows, and the outputs are derived data rather than user actions. The audit log would fill with rebuild noise.
- User-initiated
memory_writestill goes through the normal tool dispatch path and lands inauditwith full reasoning. The user asked, so the user's action is auditable.
If you need to know when a personalization rebuild last ran,
query trading_summary_updated_at or tags_next_update on the
row directly. For individual personalization memory writes, you
can still grep the memories table by
category = 'personalization' and filter by created_at.
Configuration
Cooldown intervals, LLM model, and rebuild-on-boot behavior are
configured in
apps/agent/src/memory/financial-profile-config.ts.
The defaults are a direct port of v1:
tradingSummaryCooldownMs: 30 minutestagsCooldownMs: 30 daysmemoriesCooldownMs: 10 minutesrebuildOnBoot:false
Rebuild is a no-op when no LLM client is provided (used in tests).
Turn the whole service off in production via
MINARA_PERSONALIZATION_REBUILD=disabled if you need to, but know
that durable memories never age into the personalization
category without it.
The /profile REPL command
Dump the current personalization snapshot from inside the REPL:
/profilePrints the financial profile row, active user tags, recent
personalization memories, and the custom prompt fragment (if
any). This is the fastest way to debug "why is the agent
behaving this way": if the profile says risk: conservative
but the agent is suggesting 10x leverage, something upstream
is broken.
Editing profile fields
Direct field edits go through the config CLI:
minara config get financial.custom_prompt
minara config set financial.custom_prompt "Always prefer stablecoin pairs."Tag edits go through the personalization service. The simplest path is to let the agent infer from conversation. Direct manual upserts are supported via a small internal helper for CI seeding but are not exposed as a CLI command.
Safety notes
- Personalization rebuild is LLM-driven. The Haiku call is
cheap but not free; set
MINARA_DAILY_CAP_USDin production. A misconfigured rebuild loop can silently burn budget. custom_promptis trusted input exactly likeSOUL.md. A user who can edit it can change the agent's behavior. For multi-tenant deployments, gate writes behind your own auth layer.- Tag inference is not ground truth. The tag vector is a best-effort inference from a chat and trading sample. The agent treats declared values as stronger than inferred ones, but neither should be used to override explicit user instructions mid-conversation.
- Partial rebuilds leave stale state. If an LLM call times out mid-rebuild, the cooldown still advances. The next rebuild runs normally; the gap shows up as a brief stale window. Tune cooldowns accordingly.