> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ziro-agent.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> How a Ziro turn moves through the LangGraph state machine, where state is stored, and how the engine and frontend packages are split.

Ziro is a LangGraph agent with persistent memory, RAG, progressive tool loading,
MCP support, a skills library, guardrails, and automatic context compaction. This
page describes the shape of a single turn and the runtime that surrounds it.

## The agent loop

Every turn runs through the same state machine:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
User input → [input_guard] → [compaction] → agent node → LLM with tools → DynamicToolNode → back to agent
                                                  │                                              ↑
                                                  ├─ empty reply → reprompt ─────────────────────┘
                                                  ├─ empty reply (persists) → reply_fallback
                                                  └─ done → [output_guard] → final response
```

`input_guard`, `output_guard`, and `compaction` are **conditional** nodes. The
guards toggle via `guardrails_policy.yaml`, compaction via
`compaction_policy.yaml`. When they are disabled they are not in the graph at all.

### The nodes

<AccordionGroup>
  <Accordion title="input_guard">
    Evaluates the incoming user message against the agent's input guardrail
    policy. If the guard blocks, the graph routes straight to `END` with a
    refusal message and **no LLM call is made**. See
    [Guardrails](/concepts/guardrails).
  </Accordion>

  <Accordion title="compaction">
    Runs once per turn at a clean turn boundary, between `input_guard` and
    `agent`. If the whole request (system prompt, running summary, bound tool
    schemas, and messages) exceeds `trigger_pct` of the usable input budget, it
    picks an orphan-safe split point, folds the dropped span into a running
    summary, and emits `RemoveMessage` for the dropped ids. That shrinks both the
    in-flight request and the persisted checkpoint. See
    [Compaction](/concepts/compaction).
  </Accordion>

  <Accordion title="agent">
    The LLM call. Before each call the node injects into the system prompt:

    * the configured persona from `agent_config.yaml` (composed with the active
      flavour's persona; see [Flavours](/concepts/flavours))
    * the workspace `AGENTS.md` block (top-level turns only)
    * the derived project-memory index
    * long-term user memories loaded from the store
    * the running compaction summary

    The LLM is bound with the core tools, the meta-tools, and whatever deferred
    tools have been activated for this thread.
  </Accordion>

  <Accordion title="DynamicToolNode">
    Executes the tool calls the LLM requested, then routes back to `agent`.
    Every call passes through the `pre_tool` hook chain first, which is where the
    permission gate lives: a `deny` becomes an error `ToolMessage`, an `ask`
    raises a human-in-the-loop interrupt. Tool output is capped at ingestion
    (`max_tool_message_tokens`) before it enters the message history.
  </Accordion>

  <Accordion title="reprompt and reply_fallback">
    A reasoning-only turn that produces no text and no tool calls is re-prompted
    once (the `reprompt` node, bounded by `MAX_EMPTY_REPROMPTS`). If the empty
    reply persists, a non-blank `reply_fallback` message is served instead, so a
    turn never ends with a blank response.
  </Accordion>

  <Accordion title="output_guard">
    Evaluates the final assistant message against the output guardrail policy
    before it reaches you. A block routes to `END` with a refusal.
  </Accordion>
</AccordionGroup>

## Storage: two runtime modes

The runtime mode is selected by a single environment variable, `DATABASE_URL`.

|                        | Dev (no `DATABASE_URL`)                      | Prod (`DATABASE_URL` set) |
| ---------------------- | -------------------------------------------- | ------------------------- |
| Long-term memory store | `SqliteStore` (`./data/memories.db`)         | `PostgresStore`           |
| Checkpointer           | `AsyncSqliteSaver` (`./data/checkpoints.db`) | `AsyncPostgresSaver`      |
| Vector store           | FAISS                                        | PGVector                  |

Nothing else changes between the two modes: the graph, the tools, and the
policies are identical.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker compose up -d          # start PostgreSQL + pgvector for prod mode
```

The **checkpointer** is what makes `--thread <THREAD_ID>` resumption work: the
conversation content lives in checkpoints, and `<DATA_DIR>/threads.json` holds
only the metadata (user, agent, flavour, title, timestamps).

The **store** holds long-term facts, keyed `("memory", user_id, agent_id)`, or
`("memory", user_id, flavour)` when a flavour is active, so every agent in a
flavour shares one memory. See [Memory](/concepts/memory).

## Package split

Ziro is a `uv` workspace monorepo with a deliberate seam between the engine and
the frontends.

<Columns cols={2}>
  <Card title="packages/ziro-core" icon="cpu">
    The engine. Import package `ziro`. A workspace member with its own
    `pyproject.toml` and wheel: the graph, tools, memory, RAG, guardrails,
    compaction, plugins, and the LLM provider registry.
  </Card>

  <Card title="app/" icon="terminal">
    The frontends: `main.py`, the Textual TUI in `tui/`, the slash-command
    surface in `commands/`, the onboarding flow in `onboarding/`, and the thin
    CLI entry points in `cli/`.
  </Card>
</Columns>

### The `app.* -> ziro.*` alias finder

A meta-path alias finder in `app/__init__.py` redirects every `app.<moved>`
import (at any depth) to the identical `ziro.<moved>` module object. One
identity, no double execution. That keeps existing `app.*` imports working
unchanged: user configs, dotted paths written into `hooks.yaml` before the
migration, and back-compat scripts.

<Warning>
  New code should import `ziro.*` directly. `python -m app.<moved>` no longer
  executes `__main__` blocks through the alias: `-m` needs the real module. That
  is why the indexer commands are spelled `python -m ziro.rag.indexer` and
  `python -m ziro.tools.indexer`.
</Warning>

## Code-first agents

Alongside the YAML agent folders, `ziro.create_agent(...)` builds an agent from
pluggable objects (an `LLMAdapter`, guardrail backends, `BaseTool`s, a
`BaseStore`, a `BaseCheckpointSaver`, and policy configs) via the frozen
`AgentSpec` tree in `ziro/spec.py`.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
from ziro import create_agent

agent = create_agent(...)
reply = agent.chat("hi", user="alice")
```

`SpecProfile` adapts a spec to the `AgentProfile` duck-type the engine consumes,
and `ziro.profiles.load(<agent-id-or-path>)` parses a YAML agent into an
`AgentSpec`. Spec-driven agents never read project config layers, using spec
fields or library defaults only, and they run outside flavours.

<Note>
  The old `Ziro` class is a deprecated alias for the spec-driven `Agent`.
</Note>

## Startup shape

Two properties are worth knowing because they explain otherwise-surprising
behaviour.

**The TUI paints before the engine is ready.** `python -m app.main` paints the
Textual shell in roughly 1.6 seconds and it is usable immediately (input box,
transcript, footer) while the engine builds on a background thread. Any turn you
type before the engine is ready is queued and runs, in order, once it is. The
footer shows `● starting…` → `warming model` → `connecting MCP` → clear.

**"Defer, don't degrade."** The embedding model load is kept off the startup
critical path, but no consumer ever returns a degraded result: it waits for the
real model. `search_tools` waits while the provider is warming and then uses the
semantic index; the keyword fallback fires only on a permanent failure, never
during warmup.

Pre-cache the models so launches can go offline:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
python -m app.cli.startup
```

## Inspecting the graph

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
python -m app.cli.show_graph                  # ASCII
python -m app.cli.show_graph --format mermaid
python -m app.cli.show_graph --format png
```

## Next steps

<Columns cols={3}>
  <Card title="Agents" icon="boxes" href="/concepts/agents">
    What an agent is and how its config resolves.
  </Card>

  <Card title="Tools" icon="wrench" href="/concepts/tools">
    Progressive loading, tiers, and namespaces.
  </Card>

  <Card title="Compaction" icon="brain" href="/concepts/compaction">
    How long conversations stay inside the context window.
  </Card>
</Columns>
