MINARA

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

FilePurposePrompt placement
SOUL.mdAgent identity / personaCacheable identity block (prefix)
AGENTS.mdSession startup instructionsCacheable identity block (prefix)
USER.mdProfile of the user being helpedPersonalization snapshot (dynamic)
MEMORY.mdCurated long-term memoryMemory snapshot block (dynamic)
memory/YYYY-MM-DD.mdDaily memory notes (most recent 3 days)Memory snapshot block (dynamic)
IDENTITY.mdAgent self-description (name, vibe, emoji)Metadata only (display, never prompt)
TOOLS.mdHuman-readable tool referenceInformational; agent sees schemas instead
BOOTSTRAP.mdFirst-run-only setup instructionsMerged into identity for one turn
HEARTBEAT.mdSession state fileSee 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 injected
  • SOUL.md and AGENTS.md are cached alongside identity, so cache hit rates stay high. Keep edits small and test with /prompt afterward — a structural break blows the cache for every session until the next edit.
  • USER.md describes the user; appended to the personalization snapshot each turn.
  • MEMORY.md is curated long-term memory — prefer writing via memory_write during a session and letting compaction promote durable facts to this file.
  • memory/YYYY-MM-DD.md files are daily notes; only the most recent 3 days load.
  • IDENTITY.md and TOOLS.md never enter the prompt.
  • BOOTSTRAP.md runs 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.md

See Slash Commands → /workspace.

Workspace safety notes

  • Workspace files are trusted input. Do not hand them to untrusted users without review. A malicious SOUL.md can 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.md don't take effect until restart / /new.
  • SOUL.md and AGENTS.md are 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:

  1. financial_profile: one row per user. Trading summary, reference wallets, custom prompt fragment, visibility flags, rebuild cooldown cursors.
  2. user_tags: up to 10 rows per user, one per dimension, plus a source field that records whether the value was user-declared or inferred from behavior.
  3. Personalization-class memories: ordinary rows in the memories table under category = '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

FieldTypePurposePrompt placement
user_idTEXT PKIdentifies the user. Defaults to 'default'.
platform_wallet_summaryTEXTLLM-generated summary of the user's Minara wallet activity.Personalization block
reference_wallets_summaryTEXTLLM-generated summary of wallets the user follows as references.Personalization block
reference_wallets_jsonTEXTJSON array of reference wallet addresses.Personalization block
custom_promptTEXTFreeform user-defined instructions appended to the system prompt.Personalization block
include_memoriesINTEGERVisibility flag. 0 hides personalization memories from the prompt.Toggles block inclusion
include_trading_summaryINTEGERVisibility flag for the trading summary text.Toggles block inclusion
include_tagsINTEGERVisibility flag for the behavioural-tag line.Toggles block inclusion
trading_summary_next_updateTEXTCooldown target: earliest time the summary can be rebuilt again.
tags_next_updateTEXTCooldown target for the tags rebuild.
memories_next_updateTEXTCooldown target for personalization-memory extraction.
last_indexed_chat_idTEXTCursor for incremental chat scanning in the memory rebuilder.
trading_summary_updated_atTEXTWhen the trading summary itself was last rebuilt (distinct from row-level updated_at).
created_at, updated_atTEXTStandard 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).

DimensionAllowed values (value → label)
finance_knowledgelevel_1 Beginner / level_2 Intermediate / level_3 Advanced / level_4 Expert
frequencypassive / weekly / daily / active
markets (multi-select)crypto_majors / crypto_alts / memes / stocks / commodities / pre_ipo
riskconservative / balanced / aggressive
web3_knowledge_levellevel_1 Novice / level_2 Familiar / level_3 Experienced / level_4 Pro
stylefundamentals / technical / narrative / news_event / community
FOMO IndexVery Low / Low / Medium / High / Very High
FUD ImmunityStrong / Medium / Weak
Patience LevelHigh / Medium / Low
Greed IndexVery 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 Profilerisk, Web3 Knowledge Levelweb3_knowledge_level, Decision-Making Stylestyle, and Asset Preferencemarkets, 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_usd

spot_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.

MethodTriggersCooldown (default)InputOutput
rebuildTradingSummary()trade_history / perps_fills:recorded / external_spot:recorded events; force via /profile refresh30 minutesThree sources merged: in-session trade_history, perps_fills (off-agent perps mirror), external_spot_activities (off-agent spot mirror), plus reference walletsComposite platform_wallet_summary, reference_wallets_summary, trading_summary_updated_at
rebuildTags()Scheduled via tags_next_update30 daysProfile + trading history + enum schemaTag rows upserted into user_tags
rebuildMemories()Scheduled via memories_next_update10 minutesChats created after last_indexed_chat_idPersonalization-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:

  1. Trade event piggybackeventBus.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.
  2. Safety-net tick — every 30 minutes the app ticker calls minaraHistorySync.runIfStale() so a missed event never permanently strands the mirror.
  3. Forced refresh — CLI /profile refresh (or its HTTP twin POST /v1/profile/refresh) bypasses the throttle and runs runOnce() 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-cases is 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_write still goes through the normal tool dispatch path and lands in audit with 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 minutes
  • tagsCooldownMs: 30 days
  • memoriesCooldownMs: 10 minutes
  • rebuildOnBoot: 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:

/profile

Prints 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_USD in production. A misconfigured rebuild loop can silently burn budget.
  • custom_prompt is trusted input exactly like SOUL.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.

On this page