Skip to main content
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:
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

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.
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.
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)
  • 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.
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.
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.
Evaluates the final assistant message against the output guardrail policy before it reaches you. A block routes to END with a refusal.

Storage: two runtime modes

The runtime mode is selected by a single environment variable, DATABASE_URL. Nothing else changes between the two modes: the graph, the tools, and the policies are identical.
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.

Package split

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

packages/ziro-core

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.

app/

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

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

Code-first agents

Alongside the YAML agent folders, ziro.create_agent(...) builds an agent from pluggable objects (an LLMAdapter, guardrail backends, BaseTools, a BaseStore, a BaseCheckpointSaver, and policy configs) via the frozen AgentSpec tree in ziro/spec.py.
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.
The old Ziro class is a deprecated alias for the spec-driven Agent.

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 modelconnecting 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:

Inspecting the graph

Next steps

Agents

What an agent is and how its config resolves.

Tools

Progressive loading, tiers, and namespaces.

Compaction

How long conversations stay inside the context window.