Agents

June 7, 2026 · View on GitHub

Chaz Agents have persistent identity as Living Agents — each Agent is its own eidetica database signed by a per-Agent key. Whoever holds the key hosts the Agent. Sessions declare participating Agents by listing their pubkeys in the session's AuthSettings; routing follows key possession.

An Agent is a first-class entity (Ava, Chaz). A Worker — declared under an Agent's workers: list — is something different: a configured one-shot LLM call with no keys and no persistent identity, invocable from that Agent only via spawn_worker. The four-tier conceptual model (Peer / Agent / Worker / Resource) sits behind the names but isn't load-bearing for day-to-day yaml editing.

YAML agents: config is the bootstrap path: at startup, chaz materializes one Agent DB per yaml entry (idempotent), populating its config and meta stores from the yaml. Worker templates declared under an Agent travel with that Agent's DB as part of its config.

Defining Agents (bootstrap via YAML)

agents:
  - name: chaz
    # System prompt — `system_prompt_files` are concatenated first, then
    # `system_prompt` is appended.
    system_prompt_files:
      - ~/AGENTS.md # Tilde expands to $HOME
    system_prompt: |
      Stay terse on Matrix.
    max_iterations: 10 # Max ReAct loop iterations before forced summary
    tools: null # null = all tools, or list specific tools
    # Worker templates this Agent can invoke via `spawn_worker(name=…)`.
    # Workers have no identity of their own; entries written during a
    # Worker invocation are signed by this Agent's key.
    workers:
      - name: researcher
        system_prompt: "You are a researcher. Cite primary sources."
        max_iterations: 20
        tools:
          - web_fetch
          - calculate
          - get_time
          - remember
          - recall

      - name: coder
        system_prompt: "You are a careful Rust engineer. Edit files in-place; don't rewrite from scratch."
        max_iterations: 15
        tools:
          - shell
          - read_file
          - write_file
          - calculate
          - "filesystem.*" # Glob: all tools from "filesystem" MCP server
        presets:
          quick:
            max_iterations: 5
          deep:
            max_iterations: 30

At startup, each yaml entry becomes an Agent DB named agent:<display_name> on first boot only. On subsequent boots, existing DBs are reused without overwriting their config — yaml is a bootstrap template, and the AgentDb is the authoritative source of agent configuration once it exists. Edit live config with /agent set <ref> <field> <value>, which takes effect on the next message (no restart needed) via runtime hydration from the DB.

Because the DB — not the yaml — is authoritative after first boot, a later edit to the yaml block (or to a system_prompt_files path) only reaches an already-bootstrapped agent through a reconcile. The reconcile runs automatically at startup and on demand via /agent reload [ref]; it is hash-gated, so an unchanged yaml block leaves the DB (and any live /agent set edits) untouched, while a changed block refreshes the agent's declarative config and re-resolves its system prompt. See System Prompts.

System Prompts

An agent's system prompt is built from two AgentDbConfig fields, both optional:

FieldTypeNotes
system_prompt_filesVec<String>Paths concatenated in order. ~/~/... expansion supported. Resolved (read + folded with system_prompt) at reconcile time, not every turn. A path that can't be read is logged at warn and skipped, so a bad path degrades the prompt rather than failing the turn.
system_promptStringInline text appended after file content. The final prompt the LLM sees is <concatenated file bodies>\n\n<system_prompt>.

The resolved prompt is stored off the agent's primary DB in a content-addressed blob store (a DocStore on chaz_peer), and AgentDbConfig.system_prompt_ref holds an eidetica Snapshot pointing at it. Hydration reads the prompt by that ref (memoized — the snapshot is an immutable content address) rather than re-reading the files each turn, so the append-only config never accumulates tens of KB of prompt text and the runtime does no per-turn file IO. ContextBuilder injects the resolved text as the first chat message.

Resolution happens at three points, all sharing the same blob-store logic: the startup reconcile, /agent reload, and /agent set system_prompt[_files]. An unchanged prompt reuses the existing ref (the store grows only with distinct versions).

This is the key behavior change from the per-turn model: editing the contents of a system_prompt_files path no longer takes effect automatically — the blob is only refreshed by a reconcile. Run /agent reload <ref> (or restart) after editing a prompt file. Changing which files an agent uses is likewise stored in the DB: edit the yaml and /agent reload, or /agent set <ref> system_prompt_files <paths> directly.

Editing the system prompt

CommandEffect
/agent set <ref> system_prompt <text>Set the inline prompt text. Re-resolves the blob and updates system_prompt_ref. Takes effect next message.
/agent set <ref> system_prompt_files <path1>,<path2>Replace the file list (comma-separated, supports ~). Re-reads + re-resolves the blob on this write.
/agent reload [ref]Re-read the yaml from disk and reconcile one agent (or all). Picks up edited prompt-file contents.

The skills extension can additionally append text to the system prompt at context-assembly time via the PromptAugmentation capability — see the design note in design/skills_and_prompts.md. A dedicated user-guide page for Skills is not written yet.

Legacy roles: (deprecated)

Older configs used a top-level roles: block and an agent.role: reference. The /role slash command and role: config field have been removed from the live edit path; /role now prints a deprecation message pointing to /agent set <name> system_prompt <text>. Move any remaining role: text into the owning agent's system_prompt: field.

Agent DB schema

Each Agent DB contains the following well-known stores:

StoreKindContents
configDocStoreSerialized AgentDbConfig: model, tools, max_iterations, grants, presets, system_prompt/_files
memoryTable<MemoryEntry>The agent's own persistent key-value facts (written by remember, read by recall)
metaDocStoreAgentMeta: display_name, description, capabilities, avatar, agent-level home_pubkey
historyTable<SessionHistoryEntry>Sessions this agent has participated in (appended on attach)
memory_banksTable<MemoryBankRef>Refs to shared memory banks this agent has been granted access to (name, db_id, permission)
schedulesTable<Schedule>Agent-owned cron/one-shot wakes — see Schedules below
schedule_firesTable<...>Per-fire records (cost, errors) for schedules — used by the standalone fire path for attribution
skillsTable<Skill>Agent-local skills (prompt fragments) attached as private context
skill_banksTable<SkillBankRef>Refs to shared skill banks this agent has been granted access to

The peer maintains two in-memory indices (hosted_index::HostedIndex) — one for Living Agents and one for standalone Memory Bank DBs — built once at startup by walking eidetica's user.databases() and reading each DB's meta.kind marker (agent / bank / session, written at creation time). Both indices map db_id ↔ display_name ↔ pubkey. They exist because eidetica has no inverse "list DBs this key can access" query, and routing reads them on every session entry. Mutations from /agent new, /memory new, /agent delete, etc. update the cache directly. There is no persistent mirror — eidetica's key store is the single source of truth for "which DBs does this peer host."

Session participation

A session's authoritative participant list is its eidetica AuthSettings. Adding an agent to a session grants its pubkey Permission::Write on the session DB; revoking removes it. The SessionMeta.agents: Vec<AgentRef> field is a readable cache that stays in sync.

Auto-attach on new sessions

Freshly-created sessions auto-attach a configured roster so /agents and the model picker reflect routing reality on the very first message. The list is Config.default_agents (see configuration.md) — typically the same agent(s) you message most often. Without that config, just the first agent in agents: is attached.

This runs at session-creation time only (TUI /new, the picker's "New session" row, CLI --session, TUI startup default). It does not mutate existing sessions — those keep whatever participant list they already have. Spawned child sessions (spawn_agent / spawn_worker) also skip auto-attach since they're agent-driven and inherit context from the parent rather than the default.

Names in default_agents that don't have a hosted Agent DB are skipped with a debug log; the rest still attach. Per-agent attach failures are logged but don't unwind the rest. Session creation never fails because of default_agents.

Per-agent model overrides

Each agent on a session can have its own session-scoped model pin, independent of the session-wide /model pin. This lets researcher stay on Ring-1T while ava runs on Opus inside the same session.

CommandWhat
/modelShow the model resolved for the current agent + every override on this session
/model <id>Set the session-wide pin (SessionMeta.model)
/model <agent> <id>Set a per-agent override (SessionMeta.agent_models[agent])
/model <agent> clearClear that agent's override

The picker exposes the same controls graphically — see Model Picker / Scope tabs.

Resolution order on every turn:

  1. Per-agent override — meta.agent_models[agent_name]
  2. Session-wide pin — meta.model
  3. Agent's own default_model from its DB config
  4. Backend default

The same chain runs whether the agent turn was kicked off by a human message, an @mention chain, or a scheduled fire — run_schedule_turn and spawn_agent_task both resolve through SessionMeta::resolve_model_for_agent(name). The Matrix gateway's legacy !chaz direct-chat path doesn't run through the agent runtime and so isn't agent-scoped; live Matrix agent turns are.

/agent commands

Every transport uses the same set of commands. TUI: /agent <sub>. Matrix: !chaz agent <sub>.

Every ref is either an agent's display name or its eidetica DB ID; resolution tries display name first.

CommandWhat
/agent add <ref>Grant the agent Write permission on the session, append to SessionMeta.agents, log entry in the agent's history. Idempotent.
/agent remove <ref>Revoke the agent's session key and remove from SessionMeta.agents. History is append-only and is preserved.
/agent list (or /agents)List agents attached to the current session. The host agent is marked.
/agent host <ref>Designate the session's host agent (see turn-taking). Agent must already be attached.
/agent host (no arg)Clear the host agent.
/agent roomChat-room status: attached roster, designated host (flags a dangling host id), and the agent→agent burst-budget state (used/budget).

Lifecycle, sharing, and co-ownership

These aren't session-scoped; they act on the Living Agent itself.

CommandWhat
/agent new <name> [k=v ...]Create a new Living Agent DB. Optional k=v for model/tools/autonomous/max_iterations/tool_profile/max_context_tokens/system_prompt/system_prompt_files (same set accepted by /agent set). Worker templates are edited via yaml + /agent reload, not /agent set.
/agent set <ref> <field> <value>Edit one field on the agent's DB config. Takes effect on the next message via live hydration — no restart.
/agent reload [ref]Re-read the chaz yaml from disk and re-run the hash-gated reconcile for one agent (or all). Refreshes yaml-declared fields and re-resolves the system prompt; live /agent set edits survive when the yaml block is unchanged. Same path as the startup reconcile.
/agent hostedList every Living Agent this peer hosts (from the in-memory hosted-agents index).
/agent delete <ref>Unregister locally (index + runtime registry). The DB is preserved for archive. Refuses if the agent is still attached to any known session.
/agent share <ref>Generate a DatabaseTicket URL for the agent's DB, so another peer can sync it.
/agent unshare <ref>Stop sharing the agent's DB — disable sync so this peer stops serving it. Does not revoke keys already held by peers who imported it.
/agent import <ticket> [admin|write|read]Request access to a synced agent DB via the bootstrap workflow. Default write. If the receiver's key is preseeded, sync proceeds; otherwise queues a request for the owner's /sharing approve.
/pubkeyPrint this peer's default pubkey, for pasting into an owner's /agent invite.
/agent invite <ref> <pubkey> [admin|write|read]Preseed another peer's pubkey on this agent's DB so their /agent import succeeds without an approval round-trip. Default admin (Admin(1)).
/agent revoke-peer <ref> <pubkey>Revoke a previously-invited pubkey. Historical entries signed by it remain verifiable; no new writes. Cannot revoke this peer's own key (use /agent delete for that).
/sharing or /sharing statusList every database this peer is currently sharing, grouped by kind (agent / bank / session) with DB root IDs.
/sharing requestsList bootstrap requests pending an admin's approval on this peer (covers agents, banks, sessions — eidetica's queue is unified).
/sharing approve <id>Approve a queued bootstrap request, granting the requester their requested permission.
/sharing reject <id>Reject a queued bootstrap request.
/agent rehost <ref> [pubkey]Reassign the home peer for this session (default scope). With no pubkey, defaults to "rehost to me." See Execution ownership for what this controls.
/agent rehost --agent <ref> [pubkey]Reassign the agent-level home — the peer that runs Fresh schedule fires for this agent.
/agent rehost [--agent] --clear <ref>Clear the chosen home, restoring legacy "any keyholder runs" behavior. WARNING: re-introduces the multi-peer race.
/agent home-status [ref]Print agent-level and per-session home_pubkey for one or all locally-hosted agents. This peer's keys are tagged ← (me).

Execution ownership (home peer)

When an agent is co-owned — more than one peer holds an authorized key on the agent DB — and attached to the same session, both peers would otherwise wake on the same human message and both run the ReAct loop. You'd get a forked turn, double the token spend, and downstream burst-budget confusion. The home peer is the single peer elected to run the agent for a given session.

For a solo agent (one peer holds the key), none of this applies — you'll never notice it.

What's automatic

You don't have to do anything for the common cases. Two defaults handle them:

  • On /agent new (and yaml bootstrap), the creator becomes the agent-level home. Used only by Fresh schedule fires, where no session exists yet to carry a per-session value.
  • On every /agent add (or any other attach), the attaching peer becomes the per-session home for that (session, agent) pair. Subsequent re-attaches don't change it — only /agent rehost does.

So if Alice creates alpha and attaches it to her DM, Alice is the home. If she invites Bob and Bob attaches alpha to his DM, Bob is the home for his DM. They can both edit alpha's config and memory; their DMs don't fight over execution.

Where the state lives

ScopeStored onFieldUsed by
Per-sessionSession DB metaSessionMeta.agents[i].home_pubkeyInteractive turns, Pinned schedules
Agent-levelAgent DB metameta.home_pubkey (top-level key)Fresh schedule fires only

Both are Option<PublicKey> (string-encoded ed25519:…). None is the legacy default and means "any keyholder runs" — the pre-feature behavior. Pre-existing sessions/agents stay on the legacy path until a /agent rehost (or a new attach/create) writes a value.

Inspecting it

/agent home-status alpha
agent: alpha (db_id: sha256:abc...)
  agent-level home: ed25519:PK_A... ← (me)
  sessions (3):
    s_001 morning standup            ed25519:PK_B...
    s_002 research thread            ed25519:PK_A... ← (me)
    s_003 scratch                    <unset — legacy, any keyholder>

Without an argument (/agent home-status), every locally-hosted agent is listed. Use this to see at a glance which (session, agent) pairs you own and which belong to other peers.

Taking over (/agent rehost)

/agent rehost <ref>                        # session-level, rehost to me
/agent rehost <ref> <pubkey>               # session-level, hand to that peer
/agent rehost --agent <ref> [pubkey]       # agent-level (Fresh fires)
/agent rehost --clear <ref>                # session-level, restore legacy None
/agent rehost --agent --clear <ref>        # agent-level, restore legacy None

The default form rewrites the home for the current session to this peer's pubkey on the agent. With an explicit pubkey, the target must already be authorized on the agent DB (/agent invite first) — rehost refuses unauthorized pubkeys to prevent bricking the agent.

--clear removes the field and prints a warning: clearing re-introduces the multi-peer race on the chosen scope. Use only for recovery from stuck state.

When the home peer is offline

Failover is explicit and operator-driven in v1. If the home peer's chaz process is down, its (session, agent) pairs go silent — peer_is_home_for returns false for everyone, no one responds.

To notice it: chaz logs a WARN after the third consecutive skip per (session, agent), with the exact recovery command. Structured fields (session_db_id, agent, skipped_wakes) accompany the message:

WARN session_db_id="sha256:def…" agent="alpha" skipped_wakes=3
     Home peer has missed 3 consecutive wakes for this session/agent.
     If this is a stuck home, run `/agent rehost alpha` from a surviving peer to take over.

To recover from a surviving peer:

/agent home-status alpha          # see the current state, which pair is silent
/agent rehost alpha               # take over this session
# (next message wakes you instead)

The skip counter resets on a successful run (home matches self) and on any rehost write.

Why no auto-failover: eidetica's daemon/client split means a peer's sync engine can be up while its chaz process is down (or vice versa), so presence is unreliable to infer remotely. Any opportunistic "the home is stale, I'll claim it" would re-introduce the very fork this system exists to prevent. Revisit when eidetica grows a reliable leasing primitive.

/agent revoke-peer interaction

Revoke is the operator's call — there's no hard block when the revoked key is the current home. The success message lists any sessions and agent-level state where the revoked key was home, with the exact /agent rehost command to recover:

> /agent revoke-peer alpha ed25519:PK_B...
Revoked ed25519:PK_B... from agent 'alpha'. They retain read access to history but cannot write.

WARNING: revoked key was the home peer for 2 session(s): s_001, s_002. Their next turn will be silent until you run `/agent rehost alpha` from a surviving peer.

Cross-peer spawn_agent

Works without special handling. The spawning peer is the one that calls attach_agent_to_session on the child session, so the per-session home defaults to that peer — and the spawning peer runs the child turn. If a co-owner later syncs the child session, their gate skips it.

Migrating pre-existing co-owned setups

If you had co-owned agents/sessions before this feature landed (post Stage 10), they kept home_pubkey = None until someone runs /agent rehost. The legacy behavior — both peers respond — continues until then. To surface the migration cleanly, chaz emits a startup WARN per affected (agent, session) pair, naming the exact /agent rehost command to claim execution. Solo agents are skipped (no race possible).

Turn-taking

When a message arrives on a multi-agent session, routing picks one agent in this precedence:

  1. Explicit override (gateway/schedule directives).
  2. @<name> mention in the message text — first token matching an attached agent's display_name wins. @alpha, @beta-bot,, @gamma. all work; a@b.com is ignored (no leading @ at token start).
  3. Host agent (SessionMeta.host_agent_db_id) if that agent is still attached.
  4. First attached agent in AuthSettings order.
  5. Legacy SessionMeta.agent_name (pre-Living-Agents sessions).
  6. Default agent from yaml.

Mentions are case-insensitive and match exact display names. No prefix matching.

If a human @mentions a name that is not an attached agent (typo, or an agent that was detached), the turn does not fail — it falls through to host / first-attached. That fallback is logged at WARN (with the mentioned vs. attached names) so a misroute is observable rather than silent. Use /agent room to see the live roster and diagnose it.

Agent→agent burst budget

In a multi-agent session an agent's reply can @mention and wake another attached agent. The runaway backstop is a burst budget: the maximum run of consecutive agent-authored messages since the last human message or Directive. When the trailing burst reaches the budget, further agent→agent wakes are suppressed until a human (or a schedule) speaks again. The default is 6; operators set it via multi_agent.burst_budget in config (see Configuration). /agent room shows the current used/budget and whether it is exhausted.

Detaching the agent currently designated as host clears host_agent_db_id automatically, so a removed host can't leave a dangling pointer that silently re-routes turns.

Schedules

A schedule is a time-driven, agent-owned wake. It lives in the owning agent's DB schedules store (so it syncs and travels with the agent, exactly like its config), not in any session. The chaz RoutineEngine (one per peer) sleeps until the next due fire across every hosted agent's schedules, then runs the standalone fire path: it loads the owning agent, resolves the schedule's target, and runs that agent's turn directly. The schedule's prompt is invocation-scoped input for that turn — it is not written as a broadcast Directive entry, and there is no "resolve who responds" step (the schedule names its owner). A peer that doesn't host the owning agent silently skips, so multi-peer setups don't double-fire.

Each schedule has a target: Pinned (fire into a specific existing session) or Fresh (create a new session per fire — an autonomous recurring task). Two trigger shapes:

  • Cron triggers fire on a recurring schedule (cron: "0 */5 * * * *"). Each peer hosting the owning agent fires independently.
  • One-shot triggers fire once at an absolute fire_at, then the engine drops the schedule. These back the schedule_once tool described in Tools.

Lifecycle bounds. A cron schedule is infinite by default. Two optional bounds make finite recurring work expressible without inventing a new trigger type:

  • max_fires — retire after N fires. cron hourly + max_fires: 8 = "wake hourly for 8 hours".
  • expires_at — an RFC 3339 instant after which it stops.

Whichever is hit first retires the schedule: the fire path (the authoritative chokepoint, which holds the agent DB) persists enabled = false and the per-fire fire_count, so max_fires survives restarts and a retired schedule is not re-seeded on reload. A retired schedule is kept (disabled) rather than deleted so its history stays auditable — schedule_list shows the bounds and a [fired N×] counter. (Implementation note: the in-memory routine keeps its cron slot until the next reload/restart, but every post-bound tick early-returns at the fire path without running the agent — correctness is at the chokepoint, not the heap.)

A schedule whose Pinned session is gone, or whose owning agent is no longer a member, self-skips at fire time (logged, not errored) — there is no detach/delete sweep.

Fire timing is sleep-until-next, capped at a 5-minute idle wake so a wall-clock jump can't strand a routine. The engine fires due rules within seconds of their scheduled time rather than waiting for a poll interval.

Entry points

Routines are created two ways, both compiling to the same Routine rows fired by the one engine:

  • Interactive — the /schedule add|remove|list command and the schedule_add|modify|remove|list / schedule_once tools (this section and Tools). This is the only interactive surface; there is no separate /schedule command.
  • Static config — the schedules: block in the chaz config (Configuration), translated into session-scoped routines at startup.

When rules fire

Firing is server-side and independent of any UI. A rule fires whenever chaz is running and the session is registered — you do not need the session open or focused in the TUI, and for Matrix no one needs to be in the room. The agent turn runs on the server regardless; a gateway only affects when you see the result. (Agent-owned schedules use the standalone fire path described above — no Directive entry is written, the schedule's prompt is passed as invocation-scoped input. Static-config session routines still write a Directive into their target session.)

  • chaz must be running. The engine is one per-process task, not a system cron. While chaz is down nothing fires, and a missed cron tick is skipped, not backfilled (last_fired just anchors the next fire after restart). A one-shot whose fire_at passed while down fires once on the next start.
  • The session must still be registered. Closing/deregistering a session prunes its routines from the engine, so a closed session stops firing.
  • The target agent must be hosted on this peer — otherwise the handler silently skips (the multi-peer dedupe above).
  • Changes are live. /schedule add|remove, schedule_modify, and schedule_once take effect on the running engine immediately — no restart needed.

/schedule commands

Cron uses 6 fields: sec min hour day_of_month month day_of_week.

CommandWhat
/schedule list (or bare /schedule)List rules on the current session. One-shot rules are rendered with an @YYYY-MM-DD HH:MM:SSZ marker in place of the cron.
/schedule add <id> <sec> <min> <hour> <dom> <mon> <dow> <agent_ref> <task…>Upsert a cron rule keyed by <id>. Task may contain @mentions.
/schedule remove <id>Remove a rule by id (cron or one-shot).

To create a one-shot rule from the TUI, agents call the schedule_once tool. There's no slash-command form yet.

Example — make researcher post a morning briefing to the current session weekdays at 09:00:

/schedule add brief 0 0 9 * * Mon-Fri researcher Summarize overnight activity and surface anything urgent.

Tool Narrowing

Tool access is controlled at two levels:

  1. Agent / Worker definition: the tools: field restricts which tools an Agent (or a Worker template under it) can see. Supports exact names and glob patterns ("filesystem__*" matches all tools from that MCP server namespace — the __ separator matches the namespacing chaz uses for discovered MCP tools). tools: null (or omitted) means "inherit from parent" on a Worker; on an Agent it means "all tools".
  2. Transitive narrowing: When an Agent spawns a child — a peer Agent (spawn_agent) or a Worker (spawn_worker) — the child's resolved tools are the intersection of the parent's tools and the child's tools: list.

A child can never have more tools than its parent, even if its definition allows them.

Spawn Permissions

Worker scoping replaces the previous cross-Agent can_spawn permission system:

  • A Worker is invocable iff it's declared under the calling Agent's workers: list. There is no global Worker registry.
  • A peer Agent is invocable via spawn_agent iff it's hosted on the local peer's agent index. There is no allowed_callers gate any more.

Spawn depth is bounded by the call-depth cap (a hard recursion limit). The top-level Agent's max_iterations seeds a shared iteration budget — a single atomic counter that descends through spawn_worker invocations. Each ReAct turn in the Agent or in any nested Worker consumes one unit; when the pool reaches zero, force-summary kicks in at whichever level drained it. Worker max_iterations overrides are ignored once a parent budget is in scope: nested Workers share, they don't reset. Delegating to a peer Agent via spawn_agent is the opposite — that target gets its own freshly-seeded budget because it's running on its own identity.

Presets

Agents can define named presets that override fields:

presets:
  quick:
    max_iterations: 5
  deep:
    max_iterations: 30
    role_suffix: "Be thorough and explore multiple angles."

The calling agent can request a preset via the spawn_agent tool:

{ "agent": "researcher", "task": "...", "preset": "deep" }

Synchronous vs Asynchronous Spawn

By default, spawn_agent waits for the child agent to complete and returns the result. With "async": true, it returns immediately and the child runs in the background:

{ "agent": "researcher", "task": "...", "async": true }

Async spawns return the child session ID, which can be found via /sessions in the TUI.

How Spawn Works Internally

When an agent calls spawn_agent:

  1. A new session database is created via the server's register_child_session.
  2. A Directive entry is written to the child session.
  3. The server's on_local_write callback detects the directive and spawns an agent task.
  4. The agent runs the ReAct loop, writing Ack, ToolCall, ToolResult, and response entries.
  5. A completion signal notifies the parent (for synchronous spawns).
  6. The parent reads the response from the child session.

This routes through the same server processing path as user messages, unifying all agent invocation.

Lifecycle Overview

An agent moves through a small number of states, and the commands that drive those transitions mirror them closely.

stateDiagram-v2
    [*] --> Declared: yaml agents: entry
    Declared --> Hosted: bootstrap creates AgentDb + key
    [*] --> Hosted: /agent new <name>
    Hosted --> Attached: /agent add <ref>
    Attached --> Running: message / Directive arrives
    Running --> Attached: response written
    Attached --> Hosted: /agent remove <ref>
    Hosted --> Invited: /agent invite <peer-pubkey> (preseed)
    Invited --> CoHosted: /agent share → /agent import (peer B)
    Hosted --> Requested: /agent share → /agent import (peer B, no preseed)
    Requested --> CoHosted: /sharing approve (owner) → re-run /agent import (peer B)
    CoHosted --> Hosted: /agent revoke-peer (by owner)
    Hosted --> [*]: /agent delete (index row only; DB preserved)

Key invariants:

  • Hosted means this peer holds the per-agent private key — eidetica authorisation, not a config flag, is what decides. The in-memory agents index (built from eidetica's tracked-DBs list at startup) reflects which DBs that's true for.
  • Attached means the agent's pubkey has Permission::Write on a specific session DB. A single agent can be attached to many sessions.
  • Every transition writes to an eidetica DB; there is no in-memory-only agent state that survives a restart.

End-to-End Walkthrough: Creating and Using an Agent

This walks through the full lifecycle against the TUI. Matrix uses the same commands under !chaz <cmd>.

1. Create the agent

Either declare it in yaml (bootstrapped once, on first start):

agents:
  - name: researcher
    system_prompt: "You are a research assistant. Cite primary sources."
    max_iterations: 20
    tools: [web_fetch, calculate, remember, recall]

…or create one live:

/agent new researcher max_iterations=20 tools=web_fetch,calculate,remember,recall
/agent set researcher system_prompt "You are a research assistant. Cite primary sources."

/agent new parses k=v overrides by whitespace, so values can't contain spaces — set a multi-word system_prompt in a follow-up /agent set (which takes the remainder of the line verbatim). Either path produces an Agent DB (agent:researcher) signed by a fresh per-agent key, plus a row in the local agents index.

2. Tweak config without restarting

Edits flow through /agent set. The server re-reads each agent's AgentDb::config per message, so changes take effect on the next message:

/agent set researcher max_iterations 30
/agent set researcher system_prompt "You are a deep-research analyst. Cite primary sources and link your reasoning chain."

No yaml reload, no restart.

3. Attach the agent to a session

/agent add researcher
/agent list

/agent add writes the agent's pubkey to the session's AuthSettings (authoritative), mirrors an AgentRef into SessionMeta.agents (readable cache), and appends a row to the agent's own history Table.

4. Send messages — with or without mentions

If there's only one agent attached, every message goes to it. Attach a second agent and you pick per-message with @name:

/agent add coder
@researcher summarise the linked paper
@coder write a minimal repro in Rust

Unmentioned messages fall through: host agent → first attached → default.

5. Delegate via spawn_agent or spawn_worker

Two delegation paths from inside an Agent's ReAct loop:

  • spawn_agent — invoke a peer Agent (with its own keys and persistent memory). The target must be hosted on this peer.
  • spawn_worker — invoke a Worker template declared under the calling Agent's workers: list. No identity, no keys; entries are signed by the parent Agent.

Worker invocation (most common case for delegated work):

{
  "name": "researcher",
  "task": "find the canonical reference",
  "preset": "deep"
}

Peer-Agent invocation:

{
  "agent": "ava",
  "task": "context-check this rewrite",
  "preset": "deep"
}

The parent blocks on completion (sync) or gets a child session ID back (async: true) and continues.

sequenceDiagram
    participant U as User
    participant S as Session DB
    participant C as coder (ReAct)
    participant CS as Child session DB
    participant R as researcher (ReAct)

    U->>S: Message "@coder …"
    S-->>C: on_local_write → dispatch
    C->>C: spawn_agent(researcher, task)
    C->>CS: register_child_session + Directive
    CS-->>R: on_local_write → dispatch
    R->>CS: Ack → ToolCall/ToolResult… → response
    CS-->>C: completion signal
    C->>S: final response
    S-->>U: rendered reply

6. Schedules

/schedule add brief 0 0 9 * * Mon-Fri researcher Summarise overnight activity

The schedule is stored in researcher's DB. Every peer hosting researcher runs its own copy (sleep-until-next, no polling). When it fires, the standalone path runs researcher's turn directly with the prompt as invocation input — no Directive is written — whether or not anyone is watching the session.

7. Share the agent with another peer (co-ownership)

Chaz uses a preseed-pubkey model for co-ownership: keys stay peer-local, and the owner authorises a second peer by writing that peer's pubkey into the agent DB's AuthSettings. The share ticket then only carries the DB reference — no key material ever crosses the wire.

On peer B, get the pubkey to paste:

/pubkey
# → ed25519:AbCd…xyz

On peer A (the agent's current owner), invite that pubkey and share the DB:

/agent invite researcher ed25519:AbCd…xyz admin
# → Invited ed25519:AbCd… as Admin on agent 'researcher' (DB sha256:…).

/agent share researcher
# → eidetica:?db=sha256:…&pr=http:…

On peer B, import the ticket:

/agent import eidetica:?db=sha256:…&pr=http:…

Because peer B already holds a key for the DB (it's the one the owner pre-authorised), /agent import registers the agent under peer B's own key. Both peers now host the agent: either can attach it to sessions, edit config, run turns — and /agent share + /agent import can daisy-chain further peers from there. Once both peers attach the agent to a shared session, the home peer gate elects exactly one of them to actually run each turn (with /agent rehost to hand off).

/agent invite takes an optional permission — admin (default), write, or read:

TokenEidetica permissionCo-owner can …
adminAdmin(1)Edit config/memory, grant Read/Write to further peers. Cannot revoke the original owner (who holds Admin(0)).
writeWrite(10)Append memory/history entries, attach the agent to their own sessions — but not edit settings or invite others.
readReadSync + open the DB, read config/memory/history. No writes at all.

To revoke a co-owner later:

/agent revoke-peer researcher ed25519:AbCd…xyz

Historical entries the revoked peer signed remain verifiable — no new writes under that key are accepted. (Revoking this peer's own key on an agent is rejected; use /agent delete to unregister the agent locally instead.)

Note: Admin(0) stays with the creating peer — there is no "handover primary ownership" flow today. Multi-tier admin grants (Admin(2), Admin(3), …) aren't exposed yet either.

8. Detach or delete

/agent remove researcher   # session-scoped: revoke AuthSettings, keep DB
/agent delete researcher   # peer-scoped: unregister from local index (DB preserved for archive)

History is append-only; detach does not erase it.

Multi-Peer Topology

A single agent DB can be hosted by several peers simultaneously. Each peer holds its own keypair — /pubkey + /agent invite is the handshake that pre-authorises a second peer's pubkey on the DB before the ticket ever moves. The keys never sync; only DB entries do.

Each peer keeps its own row in its local agents index; eidetica sync replicates every store on the agent DB (config, memory, meta, history, memory_banks, schedules, schedule_fires, skills, skill_banks) between peers. Session AuthSettings lists each peer's pubkey separately, so routing treats them as distinct authorised writers — but only the home peer (see Execution ownership) actually runs the turn, preventing the multi-peer race.

graph LR
    subgraph "Peer A (Matrix bot)"
        A_agents[agents index]
        A_db[(agent:researcher DB)]
    end
    subgraph "Peer B (laptop TUI)"
        B_agents[agents index]
        B_db[(agent:researcher DB)]
    end
    A_db <-->|eidetica sync| B_db
    A_agents -.pubkey→db_id.-> A_db
    B_agents -.pubkey→db_id.-> B_db

Turn-taking within a session is gated by Execution ownership: for each (session, agent) pair exactly one peer is the home, and only that peer runs the turn. The agent's session-level home_pubkey is what peer_is_home_for checks before letting the ReAct loop wake.