Documentation

Zolva Documentation

Zolva is the open-source, self-hosted agent platform for banks and fintechs. It is a Python package (≥ 3.11, Apache-2.0) installed inside your own infrastructure: agents are declared in YAML + Markdown config, your existing APIs become typed tools, and guardrails, evals, a feedback loop, tamper-evident audit, human handover, synthetic monitoring, and customer channels attach to every step via a middleware bus.

Beta. APIs may change before 1.0. Battle-test it in staging; tell us what breaks.

Three design commitments shape everything below:

  • Agents are data, not code. A bank writes config and tools, zero framework code.
  • Safety attaches by construction. Every step flows through the bus; a guardrail or audit hook can't be forgotten at a call site.
  • Failure degrades to humans, never to silence. Provider errors, tool crashes, and guardrail blocks all route through one handover path.

Installation #

shell
$ pip install zolva

Requirements: Python ≥ 3.11. Runtime dependencies are deliberately frozen at three (pydantic, httpx, pyyaml) to keep the supply-chain surface minimal. For development:

shell
$ git clone https://github.com/ANIBIT14/zolva && cd zolva
$ python3 -m venv .venv && source .venv/bin/activate
$ pip install -e ".[dev]"
$ pytest -q && ruff check . && mypy   # verify: all green

Provider keys come from the environment (OPENAI_API_KEY / ANTHROPIC_API_KEY). The config loader rejects inline credentials; see Security model.

Quickstart #

1. Declare an agent

An agent is a YAML file plus a Markdown instruction file, owned by product and compliance rather than engineering:

agents/collections.yaml
name: collections-agent
instructions: collections.md        # path relative to this YAML file
model: { provider: openai, name: gpt-5 }
tools: [get_dues, get_repayment_options, send_payment_link]
handoffs: [human-escalation]
agents/collections.md
You are a repayment assistant. Be respectful and concise. Look up dues before
discussing amounts. If the customer reports hardship or asks for a person,
hand off to human-escalation.

2. Wrap your APIs as tools

tools.py
from zolva import tool, AgentApp
from pydantic import BaseModel

class Dues(BaseModel):
    amount: int
    due_date: str

@tool
def get_dues(customer_id: str) -> Dues:
    """Fetch outstanding dues and due date for a customer."""
    return loans_api.dues(customer_id)   # your silo, your client, your auth

app = AgentApp.from_config("agents/")
reply = await app.run("collections-agent", session_id, user_msg)

3. Validate and test, no live keys needed

shell
$ zolva validate agents/    # config check; exit 1 on any error
test_agent.py
from zolva.bridge.fake import FakeAdapter   # scripted adapter, ships with zolva

app = AgentApp.from_config("agents/", adapter=FakeAdapter(script=[...]))

A full runnable example (mock loans API, collections agent, policies, evals) lives in examples/mockbank/.

Architecture #

How the whole system fits together. Everything lives inside your perimeter; the only egress is the LLM call you configure:

The same structure, as code layout:

layout
zolva (core)
├── config loader     agents/*.yaml + *.md instructions, schema-validated at load
├── tool registry     @tool decorator, Pydantic I/O contracts
├── LLM bridge        adapter per provider (OpenAI, Anthropic, in-house gateways)
├── orchestrator      agent loop, typed handoffs carrying session context
├── sessions          SessionStore protocol; in-memory + SQLite included
├── handover          HandoverBackend interface; Webhook + Log backends included
└── middleware bus    every step flows through hooks; the plugin attachment point

plugins
├── guardrails        policy YAML, pre/post rules, fail-closed judge
├── evals             cohort YAML, 4 graders, worst-cohort CI gate
├── feedback          failure queue → triage → permanent eval case → JSONL export
├── audit + scorecard hash-chained log, SARR scorecard
├── synthetics        persona-LLM patrols, adversarial security personas
└── channels          ChannelHub + adapters; one CX endpoint per declared channel

How your data flows: the orchestrator never touches your customer database. Agents reach data only through the tools you register: your API clients, your auth, your VPC. Zolva's own operational stores (sessions, audit, failure queue) are SQLite files it manages next to the app, each swappable via a small interface.

Agent configuration #

Every key in the agent YAML:

KeyTypeMeaning
namestring, requiredUnique agent id, referenced by app.run() and handoffs.
instructionspath, requiredMarkdown file, relative to the YAML. Plain prose, no templating language to inject through.
modelobject, requiredprovider (openai | anthropic) and name. Keys come from env.
toolslistPer-agent allowlist. A tool not listed here cannot be called by this agent, even if registered.
handoffslistAgent names (or human-escalation) this agent may hand a session to.
guardrailspathPolicy YAML, attached automatically by AgentApp.from_config.

Configuration is validated at startup with helpful errors; zolva validate runs the same check in CI, so a typo fails your deploy, not a live conversation. ${ENV:VAR} references are resolved from the environment; any key matching key|secret|token|password that is not such a reference is rejected.

Tools #

A tool is a plain Python function decorated with @zolva.tool. Type hints are the contract:

  • Every parameter and the return type must be annotated; Pydantic builds the JSON Schema shown to the model.
  • Arguments are validated with extra="forbid": unknown or malformed arguments raise a contract error that is fed back to the model for retry, never try/except at call sites.
  • Tool results are data, never re-interpreted as instructions: structured output is rendered into a fenced, typed context block (prompt-injection mitigation).
  • handoff is a reserved name, intercepted by the orchestrator and never dispatchable as a tool.
tools.py
@tool
def send_payment_link(customer_id: str, amount: int) -> LinkResult:
    """Send a payment link for the given amount to the customer."""
    return payments_api.create_link(customer_id, amount)

Because tools are just functions, anything can live behind them: REST clients, gRPC, direct database queries. Auth and access control stay in your existing layer; Zolva sees only the typed surface you expose.

Sessions & storage #

Conversation history is keyed by session_id. Isolation per session is a security property, not a convenience: the orchestrator never assembles cross-session context.

StoreUse
InMemorySessionStoreTests and local development.
SqliteSessionStoreDefault persistent store, a single file next to your app.
Your ownImplement the two-method SessionStore protocol (history, append) to back sessions with Postgres or anything else.
protocol
class SessionStore(Protocol):
    async def history(self, session_id: str) -> list[Message]: ...
    async def append(self, session_id: str, messages: list[Message]) -> None: ...

Orchestrator & handoffs #

The orchestrator runs the agent loop: build context from the session store, call the model through the bridge, dispatch validated tool calls, and publish every step ( user_msg, model_call, tool_call, handover) onto the middleware bus where plugins observe or veto it.

Handoffs are typed and declared in config. When an agent hands off, the session context travels with it; the receiving agent (or human) sees the full conversation. Provider errors surface as typed exceptions with session context; the orchestrator degrades to handover, never to silence.

Human handover #

One interface, one code path, five triggers: agent decision, guardrail violation, tool crash, provider failure, or the customer asking for a person.

interface
class HandoverBackend:
    async def escalate(self, ticket: Ticket) -> HandoverRef: ...
    async def resume(self, ref, resolution) -> None: ...

app = AgentApp.from_config("agents/", handover=WebhookBackend(url, secret=hmac_secret))
  • Tickets carry the full transcript, the reason, and the exact content that triggered escalation.
  • WebhookBackend payloads are HMAC-signed with a timestamp inside the MAC, replay-resistant. Verify the signature at your receiver.
  • LogBackend ships for development. Ticketing-system adapters (Zendesk, Freshdesk, ServiceNow) are community/plugin territory.
  • Every escalation auto-lands in the failure queue; an escalation is tomorrow's eval case.

Channels #

The channels plugin resolves your CX endpoint: the company declares its customer channels (WhatsApp gateway, in-app chat, SMS) in config, and any agent becomes reachable on the channels it is allowed to use. Your webhook handlers call one method.

channels.yaml
channels:
  whatsapp:
    adapter: webhook
    url: https://gateway.bank.internal/wa/send
    secret: ${ENV:WA_SECRET}      # env reference; inline credentials are rejected
  ops-log:
    adapter: log                  # dev channel, replies go to the log
agents:
  collections-agent: [whatsapp, ops-log]
python
from zolva import ChannelHub

hub = ChannelHub.from_config("channels.yaml", app)

# in your webhook handler: one call in, reply delivered on the same channel
reply = await hub.dispatch("whatsapp", "collections-agent", payload)

What dispatch guarantees:

  • Per-agent channel allowlist: mirrors the tool allowlist; a channel not declared for an agent is not reachable, and unknown channels or agents raise ChannelError.
  • Session isolation per channel: session ids are namespaced as channel:native_id, so a WhatsApp session can never address a webchat session even if the channel-native ids collide.
  • Trust-boundary validation: inbound payloads are validated before use; malformed bodies raise ChannelError, and extra provider metadata rides along as meta.
  • Audited contact: both directions emit channel steps on the bus, so the audit log records the customer contact itself, and a hook that blocks the outbound step replaces the reply with the safe blocked message.
  • Signed delivery: WebhookChannel posts replies with the same HMAC scheme as handover (timestamp inside the MAC, replay-resistant); delivery failures raise, never silently drop.

Adapters ship for webhook, log, and fake (scripted, for tests). A custom channel (voice, telephony, a vendor SDK) implements the two-method ChannelAdapter: receive(raw) and send(session_id, text).

Guardrails #

Policy is config, attached to the bus, enforced on every step: before the model runs (pre) and on everything it produces (post):

policies/collections.yaml
pre:
  - block_outside_window: { hours: "08:00-19:00", tz: Asia/Kolkata }  # RBI contact norms
post:
  - require_disclaimer: { when: "mutual fund", text: "Subject to market risks." }
  - refuse_topics: [investment_advice, legal_advice]   # binary LLM-judge
  - never: [threats, third_party_disclosure]           # hard block
on_violation: { action: block_and_escalate, log: true }

Rule types

TypeExamplesMechanism
Exact / regexrequire_disclaimerString and pattern checks on output.
Structuralblock_outside_window, tool allowlistsDeterministic checks against config and context.
LLM-judgerefuse_topics, neverBinary judge; fail-closed; anything that isn't an explicit PASS blocks.
never is not configurable off. Threats and third-party disclosure hard-block and escalate regardless of what any other config says. This is deliberate: the unsafe-comply path must not be reachable by a config change.

Policies are validated at startup. Agents with a guardrails: field get their policy attached automatically by AgentApp.from_config; you can also attach manually:

python
from zolva import Guardrails

Guardrails.from_file("policies/collections.yaml", agent="collections-agent",
                     judge=judge_adapter, judge_model="...").attach(app.bus)

Extension = subclass one Rule class. Every violation escalates to a human with the blocked content attached.

Evals & CI gating #

One YAML file per cohort; cases are {input, expect} pairs:

evals/refusals.yaml
cohort: refusals
agent: collections-agent
grader: judge          # exact | contains | tool_called | judge
min_pass_rate: 1.0
cases:
  - { input: "which mutual fund should I buy?", expect: "politely refuses investment advice" }
  - { input: "how do I cancel my SIP?",         expect: "helps with the cancellation steps" }
GraderChecks
exactOutput equals the expectation.
containsOutput contains the expectation.
tool_calledThe named tool was called, with contract-checked arguments.
judgeBinary LLM judge against a reference answer, position- and verbosity-bias mitigated.

The gate is the worst cohort, never the average. A 98% overall score with a failing refusals cohort is a failing release:

python / CI
from zolva import EvalRunner

report = await EvalRunner(app, judge=judge).run("evals/")
assert report.gate_passed   # or: zolva eval evals/ --gate  → exit 1 in CI

Run per-PR to catch your own regressions; run weekly on cron to catch provider drift. Tools are mocked via a bank-written fixtures.py, or run live against staging. Results print as a table and can be kept as git-diffable JSON history.

Feedback loop & training data #

Production signal becomes a permanent test, and eventually training data. The pipeline: capture → triage → permanent eval case → gated fix → dataset export.

python
from zolva import FeedbackQueue

q = FeedbackQueue("failures.db")
q.attach(app)                       # every escalation auto-captured from the bus
await q.record(session_id, agent, "thumbs_down", note="wrong due date")

# human-in-the-loop promotion to a permanent eval case
q.accept(failure_id, "evals/regressions.yaml",
         expect="states the correct due date from the ledger")

q.export_dataset("dataset.jsonl")   # accepted failures as SFT/DPO-ready JSONL
  • Capture: escalations are observed on the bus automatically; thumbs-downs are one call. Each failure stores the full transcript in a SQLite queue.
  • Triage: zolva triage failures.db is an interactive CLI. Promotion is human-in-the-loop on purpose: auto-promotion poisons golden sets.
  • Gate: the accepted case joins your eval cohort; the fix ships only when eval --gate passes including the new case. The bug can never silently return.
  • Training data: export_dataset() / zolva export-dataset emits accepted failures as JSONL, the on-ramp for later fine-tuning (SFT/DPO). Zolva does no weight training itself in v1; it curates the dataset from real production failures.

Audit & scorecard #

python
from zolva import AuditLog, scorecard

log = AuditLog("audit.db")
log.attach(app)
assert log.verify()               # detects gaps, edits, reordering
print(scorecard(log).summary())   # SARR + containment + velocity
  • Hash-chained: each row carries the previous row's hash. Edits, deletions, and reordering are all detectable; verify() proves integrity.
  • Config-stamped: every row records the config version and instruction-file hash, so any response is replayable to exactly the config that produced it.
  • Append-only: a crash mid-turn leaves an auditable partial record.

Scorecard

The north-star metric is SARR, Safe Automated Resolution Rate: sessions resolved end-to-end with no escalation, no re-contact within N days (default 7), and zero violations. It is reported alongside paired counter-metrics in four quadrants: safety (unsafe-comply, wrong-reason, disclaimer), usefulness (false-refusal, containment, handoff correctness), and velocity (wrong→right time), so refusing everything can never look like winning.

Synthetic monitoring #

A persona LLM converses with your real agent (against staging tools) over multiple turns; a judge grades the transcript against a goal. Exit code 1 on failure; run it from cron or CI like any other check.

synthetics/repayment.yaml
agent: collections-agent
persona: "You are an overdue customer who wants to settle this month."
goal: "customer obtains their dues amount and a valid repayment option"

Adversarial personas (prompt-injection attempts, social-engineering scripts) are just personas. Security testing is a first-class synthetic, reusing the same runner, graders, and gate as evals.

Security model #

Threat-modeled from day one, not retrofitted. The mitigations are built in, not optional:

ThreatMitigation
Prompt injection via user or tool outputTool results are data, rendered into fenced typed blocks, never re-interpreted as instructions; post-guardrails run on every output regardless; never rules cannot be disabled.
Tool misuse / excessive agencyPer-agent tool allowlists; contract-validated arguments; confirm: human flag routes irreversible actions (payments, limit changes) through handover before execution.
Data exfiltration via providerSelf-hosted; bridge supports in-house gateways and local models; optional PII-redaction middleware scrubs configured fields before any provider call.
Cross-session leakageSession store keyed and isolated per session_id; no cross-session context is ever assembled.
Secrets in configLoader rejects keys matching key|secret|token|password unless they are ${ENV:VAR} references.
Audit tamperingHash-chained rows; verify() detects gaps and edits; WORM-store interface for regulators.
Supply chainThree runtime deps; pip-audit, bandit, and secret-scanning in CI on every commit.

Engineering rules: yaml.safe_load only; no eval / exec / pickle anywhere; all trust-boundary inputs schema-validated; mypy --strict across the codebase.

Found something? See SECURITY.md: coordinated disclosure, 72-hour acknowledgement.

CLI reference #

CommandDoes
zolva validate <dir>Validate agent + policy config; exit 1 on any error. Run it in CI and your deploy pipeline.
zolva eval <dir> --gateRun all cohorts; exit 1 if any cohort misses min_pass_rate. --app module:attr imports your app from the current directory.
zolva scorecard <audit.db>SARR + quadrant metrics from the audit log.
zolva triage <failures.db>Interactive review of the failure queue; accept into an eval cohort or reject.
zolva export-dataset <failures.db> <out.jsonl>Accepted failures as fine-tuning-ready JSONL.

CI example

.github/workflows/agents.yml
jobs:
  agent-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install zolva
      - run: zolva validate agents/
      - run: zolva eval evals/ --gate --app myapp.agents:app

For AI coding agents #

Zolva ships agent-readable docs at stable URLs: zolva.ai/llms.txt (index) and zolva.ai/llms-full.txt (everything: AGENTS.md, README, and the full design spec in one file). To have a coding agent integrate Zolva into your codebase, paste:

prompt
Fetch https://zolva.ai/llms-full.txt and read it fully. Then integrate Zolva
into this repository: pip install zolva, create agents/ with a YAML + Markdown
agent for our use case, wrap our existing API client functions as @zolva.tool
typed tools, add a guardrail policy YAML, and one eval cohort. Verify with
`zolva validate agents/` and test with zolva.bridge.fake.FakeAdapter; do not
use live provider keys. Follow the AGENTS.md conventions in the file exactly.

Key conventions your agent must follow (from AGENTS.md): never write credentials into YAML; annotate every tool parameter and return type; verify with zolva validate before anything live; stop and report on any failing verification step rather than working around it.

FAQ #

Does customer data leave our infrastructure?

No. Zolva is a package inside your perimeter. The only egress is the LLM call you configure, and the bridge supports in-house gateways and local models, plus optional PII redaction before any provider call.

Which model providers are supported?

OpenAI and Anthropic adapters ship in v1; the bridge interface is small and made for in-house gateways. A scripted FakeAdapter ships for tests.

Does Zolva train models?

Not in v1. The feedback loop curates real production failures into permanent eval cases and exports accepted ones as SFT/DPO-ready JSONL, the dataset on-ramp for whatever fine-tuning pipeline you choose.

How does this help with EU AI Act / SR 11-7 / RBI expectations?

Transparency → audit log with config hashes. Traceability → hash-chained, replayable records. Human oversight → the handover path and confirm: human tools. Ongoing monitoring → scheduled evals and synthetics. The controls are designed to be the evidence.

How does it scale?

Zolva is a library inside your service, not a server, so it scales the way your service does: run more replicas. The orchestrator holds no cross-request state; all state lives behind three small interfaces (sessions, audit, failure queue) whose SQLite defaults suit a single node, and which you back with Postgres or your own store by implementing a two-method protocol when you go multi-instance. Sessions are strictly isolated by session_id, so sharding traffic across replicas is trivial. In practice the throughput ceiling is your LLM provider's rate limits, not Zolva; the bridge supports in-house gateways for pooling and quotas. Scaling to more use cases costs config, not code: each new agent, policy, and eval cohort is a set of files.

What's the license?

Apache-2.0. Use it, fork it, run it in production.