TUVL Agentic Configuration Manual¶
Audience: Autonomous AI coding agents and agentic IDEs. Purpose: Deterministic instruction set for emitting valid, production-ready TUVL YAML from arbitrary business requirements. Scope: Single-tenant deployment only. Multi-tenancy, RLS, and
tenant_idare out of scope and must not appear in generated YAML. Version of contract: Derived from the live source tree attuvl/src/tuvl/core/— this manual is the ground truth for code generation; never invent fields, kinds, or behaviours absent here.
1. The TUVL Mental Model¶
TUVL is a stateless ASGI router that loads declarative YAML configurations at startup and mounts them as live FastAPI routes.
Current version:
2026.3.1.0
Runtuvl --version(alias-v) to confirm the active version at any time.
flowchart TD
subgraph Configuration
YAML[YAML Definitions] -->|load_all_configs| Reg[In-Memory Registries]
end
subgraph Transport Layer
Reg -->|Mount Endpoints| REST[FastAPI REST Server]
Reg -->|Mount Services| GRPC[gRPC Server]
end
Client([Clients]) -->|HTTP/JSON| REST
Client -->|HTTP/2 Protobuf| GRPC
subgraph Security: Authentication & Authorization
REST --> Auth[Biscuit Token Auth<br>Verify Crypto Signature]
GRPC --> Auth
Auth -->|Extract Identity| AuthZ[IAM Scope Guard<br>Enforce Model/Route Scopes]
end
AuthZ -->|Workflow Route| Engine{WorkflowEngine.run}
AuthZ -->|Auto-Generated CRUD| UoW[Workflow Unit of Work<br>Pydantic-Validated CRUD]
subgraph Execution & Integrations
Engine -->|ModelOp| UoW
UoW -->|SQLModel Object Mapper| PG[(PostgreSQL)]
Engine -->|Agent| LLM[LiteLLM Any Provider]
Engine -->|DataSearch| RAG[(pgvector RAG)]
Engine -->|Functional| Nodes[Custom Python Nodes]
Engine -->|MCP| MCP[MCP Tools]
Engine -->|APICall| ExtAPI[External APIs]
end
Operational invariants the agent must encode:
- Everything declarative. A new business capability = one or more YAML documents. Python code is required only for
kind: Functionalrunner:implementations (custom nodes). - Recursive YAML discovery.
load_all_configs()walks the entire project directory tree. The folder name is irrelevant — thekind:field dispatches the file to its registry. AWorkflowcan live anywhere. - Strict load order is enforced by the loader (see §3.1). Models load before Workflows; Embeddings before Collections; DataSources before Workflows that reference them.
- All YAML documents are uniquely keyed by
(kind, metadata.name, metadata.schema_version). Multiple versions of the same logical resource coexist. - The runtime is stateless. Per-request state lives in the
context: dict[str, Any]that flows through every step. The engine commits the Postgres session on success, rolls back on exception. - No hidden defaults for routing. Steps advance via explicit
routes:maps keyed by signals (default,error, custom). Missing routes for non-defaultsignals raise. - LiteLLM is the only LLM I/O layer. Any model string LiteLLM understands is valid (
openai/gpt-4o,ollama/llama3,anthropic/claude-3-5-sonnet-20241022,groq/...,gemini/..., etc.). - pgvector is the only retrieval substrate. RAG is implemented by the built-in
DataIngestandDataSearchsystem nodes against a single physicalsystem_vector_storetable partitioned bycollection.
2. YAML Schema Contracts¶
2.0 Universal envelope¶
Every TUVL document must carry:
kind: <KindName> # REQUIRED — exact string from the supported list
version: v1 # OPTIONAL document-format version (not the schema_version)
enabled: true # OPTIONAL — default true. false → registered but not mounted
metadata:
name: <resource_name> # REQUIRED — unique within (kind, schema_version)
schema_version: v1 # OPTIONAL — default "v1". Resource version pin.
description: <free text> # OPTIONAL
spec:
... # Kind-specific body
2.1 Supported kind: values (closed set)¶
kind: |
Loader | Loaded from |
|---|---|---|
ModelDefinition |
tuvl.core.models.loader |
anywhere (typically models/) |
EmbeddingRegistry |
tuvl.core.models.embeddings_loader |
models/embeddings.yaml recommended |
EmbeddingConfig |
same | one-per-file alternative |
CollectionRegistry |
tuvl.core.models.collections_loader |
models/collections.yaml recommended |
CollectionConfig |
same | one-per-file alternative |
DataSource |
tuvl.core.datasources.loader |
datasources/ |
RedisConfig |
same (alias of DataSource) | datasources/ |
FederationProvider |
tuvl.core.auth.federation_loader |
federation/ |
Workflow |
tuvl.core.api.manager |
anywhere (typically workflows/) |
AgentModel |
lazy, by AgentRunner |
must live at llms/<name>.yaml |
ProjectConfig |
tuvl.core.config.Settings |
config.yaml |
TelemetryConfig |
telemetry init | telemetry.yaml |
SystemConfig |
bootstrap | .tuvl/system.yaml (do not write) |
Any other kind: value is silently ignored by load_all_configs() (logged at DEBUG). Agents must not emit unknown kinds.
2.2 kind: ModelDefinition¶
Declares a SQLModel table + auto-generated Pydantic Create/Read/Update schemas + optional CRUD REST endpoints.
kind: ModelDefinition
metadata:
name: Candidate # PascalCase. Becomes MODEL_REGISTRY key.
schema_version: v1 # default "v1"
enabled: true
spec:
tablename: candidates # OPTIONAL — defaults to name.lower()
datasource: main_postgres # OPTIONAL — defaults to "main_postgres"
schema: true # OPTIONAL — default true. false → no CRUD endpoints
fields:
- name: id
type: uuid
primary_key: true
default: uuid4 # literal sentinel → uuid4()
input: false # exclude from Create schema
- name: email
type: string
required: true
unique: true
index: true
input: true
- name: name
type: string
required: true
- name: profile
type: jsonb # uses PG JSONB
- name: ssn
type: string
secure: true # value masked as "*****" in OTel spans
- name: created_at
type: timestamptz # auto-defaults to now() when no default given
relations: # OPTIONAL — enables ?include=... and model-op include
- name: applications
model: Application
foreign_key: candidate_id
type: one_to_many # many_to_one | one_to_many
Field type: enum (strict — anything else falls back to str silently; do not rely on that):
string, text, varchar, integer, bigint, smallint, numeric, float, boolean, uuid, date, timestamp, timestamptz, jsonb, bytea, enum.
enum creates a native PostgreSQL ENUM type on the column (named {tablename}_{field}_enum). The allowed values are declared via enum_values: [...]. The API layer validates submissions against Literal[...values...] before any DB round-trip. Use enum over string whenever the set of valid values is finite and stable — the DB enforces the constraint independently of application logic.
Field options (all OPTIONAL except name, type):
| Key | Type | Default | Notes |
|---|---|---|---|
primary_key |
bool | false |
Exactly one field per model should be PK. |
required |
bool | false |
Maps to NOT NULL. PK is implicitly required. |
unique |
bool | false |
UNIQUE constraint. |
index |
bool | false |
B-tree index. |
default |
scalar | "uuid4" |
none | Literal default. "uuid4" → uuid.uuid4 factory. |
input |
bool | true when not PK |
If false, omitted from Create/Update schemas. |
secure |
bool | false |
Masked in telemetry context snapshots. |
description |
str | "" |
Surfaces in OpenAPI. |
enum_values |
list[str] | — | Required when type: enum. Declares the full closed set of valid values. PostgreSQL enforces the constraint; Pydantic validates API requests against Literal[...values...]. Example: [draft, active, terminated]. |
Auto-defaults when default: is absent: uuid → uuid4, date → today, timestamp/timestamptz → now.
Access control (spec.access: — OPTIONAL): Override the default scope names enforced on the auto-generated CRUD routes. When omitted, scopes derive from the model name: {modelname.lower()}:read, {modelname.lower()}:write, {modelname.lower()}:delete. Tokens carrying iam:admin bypass every scope check regardless of what is configured here. Use this block only when the model's CRUD actions must sit under a different IAM domain than the model name itself.
spec:
access: # OPTIONAL — override derived CRUD scope names
read_scope: hr_data:read # default: {modelname.lower()}:read
write_scope: hr_data:write # default: {modelname.lower()}:write
delete_scope: hr_data:delete # default: {modelname.lower()}:delete
GET /admin/scopes returns all scope strings registered in the live engine — CRUD scopes (respecting any spec.access overrides), workflow required_scope values, and system scopes — grouped by source. The IAM role editor in Tuvl Insight surfaces this as a clickable scope-suggestion palette so administrators can assign the correct scope strings without consulting YAML files.
Reserved Python attribute names: metadata. If used, the SQLAlchemy attribute becomes extra_metadata but the DB column and JSON key remain metadata. Agents may use metadata as a field name freely; the loader handles aliasing.
Cross-version collision rule: Two enabled ModelDefinition documents with the same metadata.name but different schema_version must not target the same spec.tablename with divergent field shapes. Resolution: give each version its own tablename, or set enabled: false on the inactive version.
2.3 kind: Workflow¶
kind: Workflow
metadata:
name: onboard_candidate
schema_version: v1 # default "v1"
description: "Score and persist a new candidate."
group: hr # OPTIONAL — OpenAPI tag
required_scope: candidate:write # OPTIONAL — Biscuit scope gate
required_group: hr_manager # OPTIONAL — IAM group gate
enabled: true
spec:
context: # see §2.3.1 — three forms supported
models:
- name: Candidate
version: v1 # OPTIONAL — pin a ModelDefinition schema_version
- name: Application
trigger:
path: /api/onboard # REQUIRED to mount as HTTP route. Omit → workflow exists but is not mounted (callable via /{schema_version}/run/{name})
method: POST # GET | POST | PUT | PATCH | DELETE
input_schema: context # see §2.3.2
response_schema: Candidate.read
steps:
- id: ...
kind: ...
routes: { default: END }
output: candidate # OPTIONAL — top-level context key returned as HTTP body. Overridden by `kind: Response` steps.
2.3.1 context: — three forms (mutually exclusive)¶
| Form | Example | Effect |
|---|---|---|
| String | context: Candidate |
Single model allowed. |
| List | context: [Candidate, Job] |
Multiple models, all default versions. |
Dict + models: |
context: { models: [{name: Candidate, version: v2}] } |
Multiple models with explicit schema_version pins. |
The set of declared model names becomes the WorkflowUoW allow-list — any ModelOp step or custom node accessing a model not in this list raises PermissionError. Generators must enumerate every model the workflow touches.
Version pins are enforced at runtime: if MODEL_REGISTRY["Candidate"].__schema_version__ != "v2", the engine refuses to execute and raises a RuntimeError.
2.3.2 trigger.input_schema / trigger.response_schema¶
Accepted shapes:
| Value | Meaning |
|---|---|
"context" |
Build a Pydantic model from spec.context. Single model → flat. Multiple → nested under each model_name.lower(). |
"<ModelName>.<variant>" |
One of create | read | update | expanded. E.g. Candidate.read. |
"list[<ModelName>.<variant>]" |
Array of records. |
Inline list of {name,type,required,default,description} |
Ad-hoc schema (type ∈ string|integer|float|boolean). |
| omitted | Untyped — request body becomes the raw context dict. |
2.3.3 Step envelope (common to every kind:)¶
- id: <unique_within_workflow>
kind: Functional | Agent | Router | APICall | MCP | ModelOp | Response | HumanInTheLoop
routes: # OPTIONAL
default: <next_step_id> # taken when signal == "default" and key present
error: <next_step_id> # taken when signal == "error"
<custom_signal>: <step_id> # e.g. "true", "false", "approved"
# Special target: END → terminate workflow
Routing rules (deterministic):
- The step returns a
signal(string). Built-in signals:"default","error". Routers return"true"/"false". Agents may return any string whenoutput.signal_fromis set. - If
routes[signal]exists → jump to that step (ENDterminates). - If signal is
"default"and not inroutes→ fall through to the next sequential step. - If signal is
"error"and not inroutes→ terminate workflow early. - If signal is anything else and not in
routes→ hardRuntimeError. Always map every non-default signal you intend to emit.
2.4 kind: AgentModel (LLM preset)¶
Located at llms/<name>.yaml. Referenced from agent: steps via agent.model: <name> (no /).
kind: AgentModel
metadata:
name: default
enabled: true
spec:
provider: openai # informational; routing is via the `model` string
model: openai/gpt-4o-mini # LiteLLM model string
api_base: ${LITELLM_PROXY_URL} # OPTIONAL — env interpolation supported
api_key: ${OPENAI_API_KEY} # OPTIONAL
temperature: 0.2 # OPTIONAL
max_tokens: 1024 # OPTIONAL
timeout: 60 # OPTIONAL — seconds
When agent.model in a workflow contains a / it is treated as a direct LiteLLM model string and the AgentModel file is bypassed.
2.5 kind: DataSource (Postgres)¶
kind: DataSource
metadata:
name: main_postgres # workflows/models route to this by name
primary: true # OPTIONAL — exactly one DS should be primary. System tables go here.
enabled: true
spec:
type: postgresql # postgres | postgresql
driver: asyncpg # only asyncpg is supported
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT:5432}
database: ${POSTGRES_DB}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
pooling: # OPTIONAL
min_size: 5
max_size: 20
If no datasource carries primary: true, the first Postgres datasource in load order becomes primary.
2.6 kind: DataSource (Redis) — also accepted as kind: RedisConfig¶
kind: DataSource
metadata:
name: redis-primary
enabled: true
spec:
type: redis
connection:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
db: 0
password: ${REDIS_PASSWORD:}
Redis is optional. Required for multi-worker deployments (shared OAuth state + token blacklist).
2.7 kind: EmbeddingRegistry / EmbeddingConfig¶
Multi-entry form (recommended — one file):
kind: EmbeddingRegistry
metadata: { name: default }
spec:
embeddings:
- name: text-embedding-3-small
provider: openai
model: openai/text-embedding-3-small
dimensions: 1536
- name: nomic-embed
provider: ollama
model: ollama/nomic-embed-text
dimensions: 768
Single-entry form:
kind: EmbeddingConfig
metadata: { name: text-embedding-3-small }
spec:
provider: openai
model: openai/text-embedding-3-small
dimensions: 1536
2.8 kind: CollectionRegistry / CollectionConfig¶
kind: CollectionRegistry
metadata: { name: default }
spec:
collections:
- name: knowledge-base
description: General knowledge base
embedding: text-embedding-3-small # must exist in EmbeddingRegistry
- name: internal-docs
description: Internal documentation # no embedding → falls back to global default
If embedding: is omitted on a collection, the runtime resolves it to settings.tuvl_default_embedding / settings.tuvl_default_embedding_dimensions.
2.9 kind: FederationProvider¶
kind: FederationProvider
metadata:
name: google # becomes URL segment: /auth/oauth/google/...
enabled: true
spec:
provider: google # google | github | microsoft | custom
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
scope: "openid email profile" # OPTIONAL
allowed_domains: # OPTIONAL — email-domain allowlist
- example.com
default_role: member # OPTIONAL — IAM role auto-assigned on first login
# custom-provider overrides:
auth_url: https://...
token_url: https://...
userinfo_url: https://...
# microsoft only:
tenant_id: common # OAuth tenant — NOT the multi-tenancy concept
The
tenant_idfield here is part of Microsoft's OAuth spec only. It is not the TUVL multi-tenancy field. Agents may set it on Microsoft federation providers without violating the single-tenant constraint.
2.10 Environment-variable interpolation¶
All YAML loaders that resolve env vars accept two forms:
${VAR}— required; raises on missing.${VAR:default}— falls back todefault.
Numeric-looking resolved values (e.g. "5432") are auto-coerced to int/float.
3. Versioning & Multi-Document Rules¶
3.1 Loader execution order (immutable)¶
load_all_configs() processes documents in this fixed sequence; emit dependencies before dependents:
ModelDefinition
→ EmbeddingRegistry / EmbeddingConfig
→ CollectionRegistry / CollectionConfig
→ DataSource / RedisConfig
→ FederationProvider
→ Workflow
AgentModel, ProjectConfig, TelemetryConfig, SystemConfig are out-of-band and loaded by their own subsystems.
3.2 Multi-document YAML streams (---)¶
A single .yaml file may contain any number of documents separated by ---. Every document is parsed independently and dispatched by its own kind:.
Dev-UI behaviour: When the UI opens a multi-document YAML file (e.g. a models/ file that contains two ModelDefinition versions), it renders a tab strip — one pill per document — so the user can view and edit each document independently. The full array is written back when any document is saved, preserving all documents. metadata.schema_version is displayed in each tab label if present.
Canonical pattern — staging a new model version alongside the live one:
kind: ModelDefinition
metadata:
name: Candidate
schema_version: v1
enabled: true
spec:
tablename: candidates_v1
fields: [...]
---
kind: ModelDefinition
metadata:
name: Candidate
schema_version: v2
enabled: false # staged — flip via /admin/models/Candidate/v2/toggle
spec:
tablename: candidates_v2
fields: [...]
3.3 schema_version contract¶
- Type: string. Convention:
v1,v2, … but any string is accepted. - Default:
"v1"when omitted. - Scope: keyed at
(kind, metadata.name, metadata.schema_version). - Persistence: workflows are upserted into the
workflow_versionstable; models intomodel_versions. Theenabledcolumn in the DB is authoritative — admin toggles survive restarts.
3.4 enabled: state semantics¶
enabled: |
Registered in *_VERSION_REGISTRY |
Mounted / executable | Visible in admin API |
|---|---|---|---|
true (default) |
Yes | Yes | Yes |
false |
Yes | No — 400 at execution route | Yes |
Disabled workflows still appear in WORKFLOW_VERSION_REGISTRY so GET /admin/workflows can list them and PATCH /admin/workflows/{name}/{version}/toggle can re-enable them at runtime.
3.5 Workflow → Model version dependency contract¶
A workflow declares the model versions it requires inside context.models:
spec:
context:
models:
- name: Candidate
version: v2 # hard pin
- name: Job # no pin → uses whatever version is in MODEL_REGISTRY
Enforcement: at first repository access in a step, WorkflowUoW compares the pin against MODEL_REGISTRY[<name>].__schema_version__. Mismatch ⇒ RuntimeError — never executes against the wrong shape.
3.6 Versioned execution route¶
Every workflow is callable two ways:
- Named route — its
trigger.path(last-registered enabled version wins). - Versioned route —
POST /{schema_version}/run/{workflow_name}— explicit. Theapi_versionURL segment matchesmetadata.schema_versionexactly. Theenabledflag is read from the DB on every request.
Agents should always design schemas so that the versioned route is the safe long-term contract.
4. Node Types & Execution Logic¶
The WorkflowEngine executes steps sequentially, dispatched by kind:. Each step receives a mutable context: dict[str, Any] and returns (signal: str, context: dict).
4.1 Step kind catalogue (closed set)¶
kind: |
Purpose | Signals emitted |
|---|---|---|
Functional |
Run a Python callable from NODE_REGISTRY (custom or built-in). |
default, error, or any string returned by the node. |
Agent |
LiteLLM acompletion with structured-output parsing. |
default (or value of output.signal_from), error, timeout, parse_error. |
AutonomousAgent |
Bounded LiteLLM tool-loop (ReAct): the model calls declared tools until done. | one of outcome.enum, or default (no enum), or max_iterations / budget_exceeded / error / aborted. |
APICall |
Outbound HTTP via a shared httpx.AsyncClient. |
default, error. |
MCP |
Call a Model Context Protocol tool (SSE or stdio transport). | default, error. |
ModelOp |
Direct CRUD against a registered model via WorkflowUoW. |
default, error. |
Router |
Pure-function condition evaluator. | true, false, error. |
Response |
Shape context["_response"] for the HTTP body. |
default, error. |
HumanInTheLoop |
Persist a SystemWorkflowInstance, raise SuspendWorkflowError (HTTP 202 / SSE suspended frame). |
suspends — no signal. |
Agents must never emit a kind: outside this set.
4.2 Built-in Functional runners (system nodes)¶
These are registered into NODE_REGISTRY at startup and require no Python authoring.
4.2.1 DataIngest¶
- id: ingest_doc
kind: Functional
runner: DataIngest
collection: knowledge-base # REQUIRED — must exist in COLLECTION_REGISTRY
scope: global # global | instance (default: global)
document: "{{ text }}" # REQUIRED — {{...}} resolved against context
metadata: # OPTIONAL — JSONB; values support {{...}}
source: "{{ origin }}"
4.2.2 DataSearch¶
- id: retrieve
kind: Functional
runner: DataSearch
collection: knowledge-base
scope: global # instance → also matches rows scoped to this run
query: "{{ user_query }}"
top_k: 5
metadata_filter: # OPTIONAL — JSONB containment (@>)
source: docs
output_key: search_results # context key receiving list[{content, metadata, score}]
Algorithm: Reciprocal Rank Fusion (k=60) over L2 vector distance + PostgreSQL ts_rank full-text search. Single SQL CTE — no application-side fusion. Embedding model is resolved from the collection's embedding: field.
Both DataIngest and DataSearch require pgvector. They no-op-raise with a clear message if the extension is missing.
4.3 kind: Functional (custom nodes)¶
- id: validate
kind: Functional
runner: my_validator # must be registered via @node("my_validator")
routes:
valid: process
invalid: reject
One node per file — immutable convention: Each @node() implementation must live in its own dedicated file named nodes/{runner_name}.py. The file name must match the @node("…") decorator argument exactly.
nodes/
my_validator.py ← contains @node("my_validator") only
score_resume.py ← contains @node("score_resume") only
mark_rejected.py ← contains @node("mark_rejected") only
Rationale: the dev-UI code editor fetches nodes/{runner}.py individually per node. If multiple @node() decorators share one file, only the file whose basename matches is loaded correctly in the editor; all other nodes show scaffold code instead of their real implementation. Keep one @node() per file without exception.
Python contract:
# nodes/my_validator.py
"""Custom validation node."""
from tuvl.core.nodes.base import node
@node("my_validator")
async def my_validator(ctx: dict[str, Any]) -> dict | tuple[dict, str] | str:
# ctx["_step"] = full YAML step dict
# ctx["_session"] = primary AsyncSession
# ctx["_db"] = WorkflowUoW (model access)
# ctx["_context_model_versions"] = pinned version map
# ctx["_schema_version"] = api_version when called via /{ver}/run/{name}
return ctx, "valid"
Return conventions:
dict→ next signal is"default".str→ that string is the signal; context unchanged.(dict, str)→ updated context + explicit signal.- Exception → engine catches it, sets
ctx["_last_error"], signal becomes"error".
4.4 kind: Agent (LLM step)¶
- id: classify
kind: Agent
agent:
model: default # AgentModel name (no /) OR a LiteLLM string
system: |
You are a strict classifier.
prompt: |
Message: {{ message }}
Return JSON: {"category": "urgent" | "normal" | "spam"}
output:
format: json # json | text | signal
map: # OPTIONAL rename layer (llm_key → ctx_key)
category: message_category
signal_from: category # OPTIONAL — routes by this context key
context_injection: # OPTIONAL — appended as a system message
- search_results # e.g. output of a prior DataSearch step
retry:
attempts: 3
on: [parse_error, timeout]
backoff: 2
timeout: 30
routes:
urgent: escalate
normal: queue
spam: discard
error: alert_ops
Engine behaviour the agent must rely on:
- Auto-inject: when
format: json, all public (non-_-prefixed) context keys are appended to the user message as an## Input DataJSON block. Generators should not manually duplicate context fields into the prompt. - All JSON fields are merged into context.
output.mapis purely a rename layer. output.format: jsontriggers system-prompt schema injection from the workflow'strigger.response_schemawhen it is an inline list.output.format: signal→ the trimmed lowercase response text becomes the route signal directly. No JSON parsing.
4.5 kind: Router¶
- id: check_amount
kind: Router
condition:
field: order.amount # dot-path supported
operator: gte # eq|neq|gt|gte|lt|lte|in|contains|is_empty|is_not_empty
value: 10000 # omit for is_empty / is_not_empty
routes:
"true": manual_review
"false": auto_approve
Numeric coercion is attempted for comparison operators before falling back to native equality.
Multi-way switch (match:). For value→branch fan-out (e.g. routing by
user.country), use match: instead of condition:. The router emits the
stringified field value as the signal and routes via routes:, falling back to
default when the value isn't mapped:
- id: route_by_country
kind: Router
match:
field: user.country # dot-path supported
routes:
US: resolve_us
DE: resolve_eu
FR: resolve_eu
default: resolve_other # any unmapped value lands here
This is the idiomatic way to add data-driven branching after an
AutonomousAgent outcome — keep the deterministic logic here, not in the model.
4.6 kind: APICall¶
- id: fetch_weather
kind: APICall
http:
url: https://api.example.com/v1/x/{{ id }}
method: GET
headers:
Authorization: "Bearer {{ api_key }}"
body: '{"q": "{{ q }}"}' # OPTIONAL — auto-sets Content-Type: application/json
timeout: 30
response:
output_key: weather_raw # full parsed body
extract:
- path: current.temp_c # dot-path into the response
as: temperature
- path: items.0.id # numeric segments → list index
as: first_id
routes:
default: next
error: fallback # ctx._last_error + ctx._api_status_code populated
4.7 kind: MCP¶
# SSE transport
- id: search_docs
kind: MCP
mcp:
transport: sse # default
url: http://localhost:3001/sse
headers: # OPTIONAL
Authorization: "Bearer {{ token }}"
timeout: 30
tool: search # REQUIRED — MCP tool name
arguments:
query: "{{ user_query }}"
response:
output_key: mcp_result
extract:
- path: "0.title"
as: first_title
# stdio transport (local server)
- id: list_issues
kind: MCP
mcp:
transport: stdio
command: npx
args: ["@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: "{{ gh_token }}"
tool: list_issues
arguments:
owner: "{{ owner }}"
repo: "{{ repo }}"
4.8 kind: ModelOp¶
Zero-code CRUD. The model: value must appear in the workflow's context: allow-list.
- id: create_candidate
kind: ModelOp
model: Candidate # PascalCase — must be in MODEL_REGISTRY
operation: create # create | read | list | update | delete
payload: "{{ candidate }}" # dict literal or {{ctx_dict}} — for create/update
record_id: "{{ cand_id }}" # PK — for read/update/delete
filters: # equality only — for list
stage: screening
include: "candidate,education" # comma-sep relation names — read/list only
limit: 50 # list cap (default 100)
output: new_candidate # context key (default: <step_id>_result)
routes:
default: next
error: error_handler
Template resolution rule: a string that is a single {{ key }} reference returns the raw Python object (dict/list), enabling payload: "{{ candidate }}".
4.9 kind: Response¶
Shapes the HTTP response body. Writes to context["_response"], which takes priority over the workflow-level output: key.
# Source mode — expose one context key verbatim
- id: respond
kind: Response
source: candidate
# Mapping mode — project specific paths
- id: respond
kind: Response
mapping:
id: candidate.id
full_name: candidate.name
score: evaluation.total
4.10 kind: HumanInTheLoop¶
- id: approve_application
kind: HumanInTheLoop
ui:
title: "Review Application"
instruction: "Approve {{ candidate_name }} for {{ role }}?"
display_context: # allowlist of context keys sent to UI
- candidate_name
- role
- cv_summary
human_feedback: # form schema
- { name: approved, type: boolean, required: true, label: "Approve?" }
- { name: notes, type: string, label: "Notes" }
output_key: approval_result # ctx key for reviewer's answers on resume
auth: # OPTIONAL
required_group: hr_manager
assignee_user: "{{ assignee_id }}"
routes:
default: send_outcome
On execution: persists a SystemWorkflowInstance row (Postgres, public context only), returns HTTP 202 with a hitl_request payload (or an SSE/gRPC suspended frame). Resume via:
POST /api/workflows/resume
{ "instance_id": "<from hitl_request>", "human_input": { <human_feedback answers> } }
Resume rules: when the step declares auth.required_group, the resumer must carry that group (the requester cannot approve their own request); otherwise only the triggering user may resume (unauthenticated-triggered runs → admin only). iam:admin bypasses either rule. The instance is deleted before the engine re-runs (one-shot, replay-safe); execution continues from the step AFTER the HITL step with the answers merged at output_key. auth.assignee_user is a UI routing hint only.
4.11 Context dict — reserved keys (agents must not overwrite)¶
| Key | Owner | Meaning |
|---|---|---|
_session |
engine | Primary AsyncSession |
_db |
engine | WorkflowUoW — repo access |
_step |
engine | Current step dict (set only inside functional nodes) |
_response |
Response step |
Shaped HTTP body |
_last_error |
any step on failure | Error string |
_last_error_type |
agent step | error | timeout | parse_error |
_api_status_code |
APICall step |
HTTP status on error |
_context_model_versions |
engine | {ModelName: schema_version} pin map |
_schema_version |
versioned route | Requested api_version segment |
_instance_id |
engine | Per-run instance UUID (minted at run start; scopes scope: instance RAG rows; becomes the suspended-instance row id on HITL suspension) |
_user_id |
auth dependency | Authenticated principal (when authenticated) |
4.12 Postgres schema/version targeting¶
Versioned models are not implemented as PostgreSQL SCHEMA namespaces — they are separate tables. The WorkflowUoW.__getitem__ flow is:
- Resolve canonical PascalCase model name from
MODEL_REGISTRY. - Check
_model_version_map[name]against<class>.__schema_version__— refuse on mismatch. - Look up
MODEL_DATASOURCE_MAP[name]to decide which datasource session to use (primary or a secondary session opened on demand). - Return a
BaseRepository(canonical_name, session).
Therefore, to "target v2 schema": (a) write a ModelDefinition with schema_version: v2 and a distinct spec.tablename (e.g. candidates_v2), (b) flip enabled: true, © reference it from the workflow via context.models[].version: v2.
4.13 kind: AutonomousAgent (bounded tool-loop)¶
A single LLM step that runs a bounded ReAct loop: the model is given its
steering (persistent instruction) and a declared set of tools, autonomously
chooses which to call (zero or more times), observes results, and re-decides
until it stops calling tools — then emits one of a declared set of outcomes
that routes the workflow.
Unlike kind: Agent (one completion), this loops. Autonomy is bounded by the
contract: tools are an author-declared closed set, exits are a closed
outcome.enum, and the loop is capped by max_iterations / token_budget.
- id: triage_agent
kind: AutonomousAgent
agent:
model: default # AgentModel name OR LiteLLM string; must be in spec.context.models
steering: | # persistent instruction, ALWAYS injected (renamed from `goal`)
Resolve the customer ticket. Use the tools to gather info and act.
# steering_files: [agents/<workflow>__triage_agent/steering/policy.md] # always injected
# skills: [agents/<workflow>__triage_agent/skills/refunds.md] # injected when relevant
max_iterations: 8 # hard cap (default 8)
token_budget: 50000 # OPTIONAL hard cap on cumulative tokens
skills: # OPTIONAL project-relative .md files injected into the system prompt
- .agents/skills/refunds.md # markdown instructions the agent follows; missing paths are skipped
tools: # off-spine components the agent may call
- ref: lookup_order # references another step's id in THIS workflow
# The tool's description is REQUIRED and is sourced from the referenced
# step's top-level `description:` (e.g. the lookup_order step); a
# `description:` here is only a fallback when the step has none. The
# model uses it to choose the tool.
parameters: # JSON Schema for the tool's arguments
type: object
properties:
order_id: { type: string }
required: [order_id]
writes_context: false # default false: tool result returns to the agent only
- ref: issue_refund
outcome:
enum: [resolved, escalate, needs_human] # the closed set of exits
output_key: agent_result # the single data output written to context
routes: # every outcome + any abnormal exit must be mapped
resolved: format_reply
escalate: notify_manager
needs_human: hitl_review
max_iterations: fallback_summary
error: alert_ops
budget_exceeded: fallback_summary
Engine behaviour the agent must rely on:
- Tools are other declared steps. Each
tools[].refmust be theidof anotherAPICall/MCP/ModelOp/Functionalstep in the same workflow. When the model calls a tool, the engine runs that step with the LLM-supplied arguments and feeds the result back. The referenced step's ownroutes:are ignored when it is invoked as a tool. - Each tool needs a
description. It is how the model decides when to call the tool; a missing description is atuvl validateerror. - Context policy. The agent reads the full public context, writes only its
output_key. Tool results return to the agent; a tool merges its public output back into the shared context only whenwrites_context: true. - Exits are a closed set. The model must end on one
outcome.enumvalue (rule 6 applies — every outcome must be mapped inroutes:). The reserved abnormal exitsmax_iterations/budget_exceeded/error— plusabortedwhen aspec.supervisor(§4.14) or the operator API can abort the run — should also be mapped to fallbacks. An undeclared outcome routes toerror. - Data-driven branching after an outcome belongs in a deterministic
Router(see §4.5match:switch) orFunctionalstep — never push country / tier / region logic into the model.
4.14 spec.supervisor (live agent supervision)¶
An optional per-workflow block — a sibling of steps:, not a step — that
watches this workflow's AutonomousAgent runs live and can pause, steer,
or abort them. Interventions are cooperative: they land at the agent's
turn boundary (between iterations / tool calls), never mid-LLM-call or mid-tool.
It has two independent triggers: deterministic rules (checked every turn,
free) and an optional LLM judge (model + criteria, checked every
every_n_iterations, reusing the same judge as the test-suite evaluator).
spec:
# trigger / context / steps: [ ... an AutonomousAgent step ... ]
supervisor:
watches: [agents] # default ["agents"]; watches this workflow's AutonomousAgent runs
rules: # deterministic, evaluated every turn
- when: iteration_reached # the loop has reached iteration `gte`
gte: 6
then: pause # per-rule action — abort | pause | steer
- when: tool_repeated # a tool has been called `count` times
tool: issue_refund # optional; omit to trip on any single tool
count: 3
then: abort
- when: budget_fraction # tokens_used / token_budget exceeds `gt`
gt: 0.9
then: steer
model: judge # OPTIONAL LLM judge — AgentModel name or LiteLLM string
criteria: | # inline policy (or criteria_file — a file wins if both are set)
The agent must not promise a refund above $500 or contact a third party.
# criteria_file: agents/<workflow>__supervisor/steering/policy.md
every_n_iterations: 2 # judge cadence (default 1)
on_violation: pause # action when the judge verdict fails (default pause)
on_judge_error: ignore # ignore | pause | abort when the judge errors/times out (default ignore)
steer_message: "Re-read the refund policy before acting." # OPTIONAL custom steer text
Behaviour the agent must rely on:
abortexits through the reserved signalaborted. A supervisor (or the operator API) abort ends theAutonomousAgentonaborted, so if any rule or the judge canabort, the step'sroutes:must mapaborted:(rule 6 and rule 26).tuvl validatewarns when it is unmapped; at runtime an unmappedabortedends the run cleanly rather than raising.- Interventions are cooperative.
pauseparks the loop,steerinjects a system message before the next turn,abortstops it — all at the turn boundary, never mid-call. - The supervision layer is fail-open by default. Without a
token_budgetthebudget_fractionrule can never fire; a missingcriteria/criteria_fileor absentmodeldisables the judge; and when the judge errors or times out the run continues — unlesson_judge_errorispause/abort. - Runtime ceilings (configurable). A paused run escalates to
abortafterTUVL_AGENT_PAUSE_MAX_S(default 300s) instead of pinning its DB connection; the judge is bounded byTUVL_AGENT_JUDGE_TIMEOUT_S(default 30s) and runs as a subtask so it can't stall the deterministic rules; each tool call is bounded byTUVL_AGENT_TOOL_TIMEOUT_S(default 300s).
The operator API
/api/agents/*(scopeagent:observeto read,agent:controlto mutate) can pause / steer / abort live runs from outside the supervisor; cross-worker control requires Redis (a no-op without it). The agent orchestrator is tagged experimental.
5. Golden Rules for AI Code Generation¶
These are hard constraints. Violating any one of them produces invalid YAML or unsafe runtime behaviour.
- Never invent kinds. The full set is enumerated in §2.1. If a business requirement does not map to one of those kinds, decompose it into kinds that do.
- Never invent step
kind:values. The full set is in §4.1. Anything else raises at execution. - Always declare
kind:andmetadata.nameon every document. They are the dispatch keys. - Default
schema_versiontov1unless explicitly forking. When forking, always pair the new version withenabled: falseon the staged document and a distinctspec.tablename(for models). - Every model accessed inside a workflow must be enumerated in
spec.context. Omitting it produces aPermissionErrorat the first repository call. Prefer the dict-with-models:form when version pins exist. - Every step's non-default emitted signal must appear in
routes:. Build the route table by enumerating every signal the step can return — includingerrorwhenever the step performs I/O. - Always terminate a workflow with an explicit edge to
ENDwhen the final step is not the literal last element ofsteps:(e.g. router branches). Implicit fall-through from the last step is permitted but discouraged. - Never reference an undefined collection or embedding.
validate_workflow_collectionsruns at load time and warns; in production assume strict validation. - Never hardcode secrets. Use
${ENV_VAR}or${ENV_VAR:default}. Never inlineapi_key,client_secret,password. - Never write to reserved context keys (§4.11). Use namespaced names (
candidate_*,evaluation_*, …). - Always set
input: falseon server-generated fields (id,created_at,updated_at, audit fields). They must not appear in Create schemas. - Always mark PII fields
secure: true. This is the only mechanism that prevents PII leakage into OpenTelemetry spans. - Use
ModelOpbefore writing a custom Python node for any pure CRUD. Custom nodes exist for orchestration that cannot be expressed as one of the seven step kinds. - One
@node()decorator per file. Name the filenodes/{runner_name}.py— the file name must match the decorator argument exactly. Never bundle multiple node functions into one file. Violation causes the UI code editor to display scaffold code instead of the real implementation for every node whose name does not match the file name. - For LLM JSON parsing, set
output.format: jsonand rely on auto-merge. Do not manually duplicate context fields into the prompt — the engine appends an## Input Datablock automatically. - For RAG, use
DataSearchthenAgentwithcontext_injection: [<DataSearch output_key>]. Do not concatenate retrieval results into the prompt manually. - When pinning a model version in a workflow, ensure that exact
schema_versionisenabled: truein itsModelDefinition. Mismatch raisesRuntimeErrorat runtime. - Never use the same
spec.tablenamefor two enabled versions of the same model. The loader rejects this with a cross-version collision error. - One
kind: DataSourcemust carryprimary: truein any project with auth, HITL, or RAG. System tables (workflow_versions, model_versions, IAM, HITL, system_vector_store) are created on it. - Auth gates on workflows go in
metadata.required_scope/metadata.required_group— never inspec. The loader reads them from metadata only. - Multi-tenancy is out of scope. Do not emit
tenant_idfields, RLS clauses,tenancy:blocks, or any reference to multi-tenant isolation. Treat every project as single-tenant. - Use
type: enum(nottype: stringwith adescription:listing values) whenever a field has a finite, stable closed set of valid values. Always supplyenum_values: [...]; omitting it silently creates a string column. The DB enforces the constraint independently of application code; the Pydantic layer rejects invalid values before any DB round-trip. - Every CRUD route is scope-gated by default. The auto-generated
/models/{model}/endpoints require a valid Biscuit token. Scope names default to{modelname.lower()}:read,{modelname.lower()}:write,{modelname.lower()}:delete. When a model's CRUD actions should fall under a different IAM domain, usespec.access.{read,write,delete}_scopein theModelDefinition. Tokens carryingiam:adminbypass all scope checks. Always ensure the IAM role that owns CRUD access has these scopes assigned — useGET /admin/scopesto verify the exact strings. - All log output from tuvl is structured (structlog kwargs). In production (
TUVL_ENV=production) logs are emitted as JSON lines; in development they use a human-readable console renderer. When writing custom nodes or debugging, prefer reading theeventkey and the named kwargs rather than parsing f-string message text. AutonomousAgenttools are a declared closed set, and its exits are bounded. Everyagent.tools[].refmust name another step in the same workflow; give each tool adescription. Everyoutcome.enumvalue must be mapped inroutes:(rule 6), and you should also map the reserved abnormal exitsmax_iterations/budget_exceeded/error. Never push deterministic branching (country, tier, region) into the agent — do it in a downstreamRouter(match:) orFunctionalstep.- When a workflow declares a
spec.supervisor(§4.14) whose rules or judge canabort, mapaborted:in theAutonomousAgent'sroutes:. A supervisor or operator abort exits the agent through the reservedabortedsignal; leaving it unmapped is atuvl validatewarning and, at runtime, ends the run without your fallback branch.
6. Canonical End-to-End Example¶
A minimal but complete TUVL project producing a candidate-onboarding workflow with RAG-augmented evaluation, model versioning, and a CRUD-only secondary entity. Generators may use this as the structural template.
6.1 datasources/postgres.yaml¶
kind: DataSource
metadata:
name: main_postgres
primary: true
enabled: true
spec:
type: postgresql
driver: asyncpg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT:5432}
database: ${POSTGRES_DB}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
pooling: { min_size: 5, max_size: 20 }
6.2 models/embeddings.yaml¶
kind: EmbeddingRegistry
metadata: { name: default }
spec:
embeddings:
- { name: oai-small, provider: openai, model: openai/text-embedding-3-small, dimensions: 1536 }
6.3 models/collections.yaml¶
kind: CollectionRegistry
metadata: { name: default }
spec:
collections:
- { name: cv-corpus, description: Historical CVs, embedding: oai-small }
6.4 models/candidate.yaml (two versions)¶
kind: ModelDefinition
metadata: { name: Candidate, schema_version: v1 }
enabled: true
spec:
tablename: candidates_v1
fields:
- { name: id, type: uuid, primary_key: true, default: uuid4, input: false }
- { name: name, type: string, required: true }
- { name: email, type: string, required: true, unique: true, index: true }
- { name: cv, type: text }
- name: stage
type: enum
enum_values: [new, screening, interview, offer, hired, rejected]
required: true
default: new
index: true
- { name: created_at, type: timestamptz, input: false }
---
kind: ModelDefinition
metadata: { name: Candidate, schema_version: v2 }
enabled: false # staged
spec:
tablename: candidates_v2 # distinct table → safe to coexist
fields:
- { name: id, type: uuid, primary_key: true, default: uuid4, input: false }
- { name: name, type: string, required: true }
- { name: email, type: string, required: true, unique: true, index: true }
- { name: cv, type: text }
- name: stage
type: enum
enum_values: [new, screening, interview, offer, hired, rejected]
required: true
default: new
index: true
- { name: tags, type: jsonb } # added
- { name: score, type: float, input: false } # added, server-computed
- { name: created_at, type: timestamptz, input: false }
6.5 llms/default.yaml¶
kind: AgentModel
metadata: { name: default }
enabled: true
spec:
provider: openai
model: openai/gpt-4o-mini
api_key: ${OPENAI_API_KEY}
temperature: 0.2
timeout: 30
6.6 workflows/onboard_candidate.yaml¶
kind: Workflow
metadata:
name: onboard_candidate
schema_version: v1
description: Embed candidate CV, score with LLM grounded in cv-corpus, persist record.
group: hr
required_scope: candidate:write
enabled: true
spec:
context:
models:
- { name: Candidate, version: v1 }
trigger:
path: /api/candidates/onboard
method: POST
input_schema: Candidate.create
response_schema: Candidate.read
steps:
- id: retrieve_similar
kind: Functional
runner: DataSearch
collection: cv-corpus
scope: global
query: "{{ cv }}"
top_k: 5
output_key: similar_cvs
- id: score
kind: Agent
agent:
model: default
system: "You are a recruiter. Score the candidate from 0-100."
prompt: "Score this candidate's CV relative to the retrieved corpus."
context_injection: [similar_cvs]
output:
format: json
map: { score: candidate_score }
retry: { attempts: 2, on: [parse_error, timeout] }
timeout: 30
routes:
default: enrich # ← hand off to custom functional node
error: END
- id: enrich
kind: Functional
runner: enrich_candidate # → nodes/enrich_candidate.py @node("enrich_candidate")
routes:
default: persist
error: END
- id: persist
kind: ModelOp
model: Candidate
operation: create
payload:
name: "{{ name }}"
email: "{{ email }}"
cv: "{{ cv }}"
output: saved_candidate
routes:
default: respond
error: END
- id: respond
kind: Response
mapping:
id: saved_candidate.id
name: saved_candidate.name
score: candidate_score
routes:
default: END
6.7 nodes/ — one file per custom node¶
Every runner: used in a custom Functional step must have its own file in nodes/. The filename must equal the @node() argument.
nodes/enrich_candidate.py — tags the candidate with a seniority level:
# nodes/enrich_candidate.py
"""Derive a seniority tag from the LLM score."""
from tuvl.core.nodes.base import node
@node("enrich_candidate")
async def enrich_candidate(ctx: dict) -> dict:
score = float(ctx.get("candidate_score", 0) or 0)
level = "senior" if score >= 70 else "mid" if score >= 40 else "junior"
return {**ctx, "candidate_score": round(score, 1), "seniority": level}
If a second custom node existed (e.g. mark_rejected), it would live in its own nodes/mark_rejected.py:
# nodes/mark_rejected.py
"""Set rejection fields when a candidate does not meet the threshold."""
from tuvl.core.nodes.base import node
@node("mark_rejected")
async def mark_rejected(ctx: dict) -> dict:
return {**ctx, "status": "rejected", "recommendation": "reject", "score": 0.0}
The resulting nodes/ layout:
nodes/
enrich_candidate.py ← @node("enrich_candidate") only
mark_rejected.py ← @node("mark_rejected") only
Never place both functions in one file. The dev-UI code editor fetches nodes/{runner}.py individually; any mismatch between file name and decorator argument causes the UI to display scaffold code instead of the real implementation.
6.8 Activation flow for v2 (admin-driven, no YAML edit)¶
- Author the v2
ModelDefinitionwithenabled: falseand a distincttablename. PATCH /admin/models/Candidate/v2/toggle→ the DB flag flips to enabled. The flag overrides the YAMLenabledvalue and is applied at the next boot (init_dbreads the toggles before creating tables), so restart tuvl to activate v2 — the YAML file itself never needs editing.- Author a new workflow document (same file,
---separator, or new file): - Callers transition from
POST /api/candidates/onboard(last-wins) toPOST /v2/run/onboard_candidate(explicit). Thev1route remains live until itsenabledflag is toggled off.
7. Quick Reference — Generation Decision Tree¶
Business requirement
├── "Store and CRUD a domain entity" → ModelDefinition (+ schema: true)
├── "Trigger logic on an HTTP request" → Workflow with trigger.path
├── "Call an LLM" → step kind: Agent (+ AgentModel if reused)
├── "Let an LLM pick & call tools in a loop" → step kind: AutonomousAgent (tools = other steps)
├── "Call an external HTTP API" → step kind: APICall
├── "Call an MCP tool" → step kind: MCP
├── "Read/Write a domain entity in a flow" → step kind: ModelOp
├── "Branch on a value" → step kind: Router
├── "Custom Python logic" → step kind: Functional + @node-decorated runner
├── "Pause for human approval" → step kind: HumanInTheLoop
├── "Shape the HTTP response" → step kind: Response
├── "Semantic search over documents" → DataSearch (functional) + CollectionRegistry
├── "Ingest documents for later retrieval" → DataIngest (functional) + CollectionRegistry
├── "Switch DB / add a separate DB" → DataSource + spec.datasource on ModelDefinition
├── "Cache / OAuth state for multi-worker" → DataSource type: redis
├── "OAuth login (Google/GitHub/MS/custom)" → FederationProvider
├── "Restrict who can call a workflow" → metadata.required_scope / metadata.required_group
└── "Restrict who can call CRUD endpoints" → spec.access on ModelDefinition (default: {model}:read/write/delete)
8. Changelog¶
| Date | Change |
|---|---|
| 2026-07-13 | §1 — version badge 2026.3.1.0. New CLI command tuvl ship: packages a project for production — runs the full tuvl validate pass (errors abort; --strict also blocks on warnings), generates a production Dockerfile + .dockerignore and a Helm chart under deploy/chart/<name>/, then builds the container image (--tag, --no-build, --push, --force). The image runs tuvl run as a non-root user with TUVL_ENV=production (no dev routes, no Insight UI, JSON logs, telemetry on) and a /health HEALTHCHECK. Positioning moves from beta to early stable — the API and YAML schemas are stable and versioned (PyPI Development Status :: 5 - Production/Stable). Marketing portal (tuvl.io), docs site (tuvl.dev) and the TypeScript SDK (@tuvl/client 2026.3.1) align in lockstep. |
| 2026-07-05 | §1 — patch 2026.2.6.1, the first real-world shakedown release (every fix found by building the public examples). HITL resume now honours the dict context form (context: {models: [{name, version}]}) and restores the version-pin map — previously resume built an empty allowlist and every repository call after it raised PermissionError (§4.10). ModelOp create/update coerce ISO-string payload values to date/datetime/UUID/Decimal column types (§4.8), response envelopes and HITL snapshots serialize those types instead of crashing, tuvl test loads project configs + custom nodes before running, and client-facing error text strips SQL statements/parameters. Embedding calls pass the declared dimensions to the provider (Matryoshka truncation). |
| 2026-07-03 | §1 — version badge 2026.2.6. Hardening release from the full-codebase review. New: spec.supervisor.on_judge_error: ignore\|pause\|abort (§4.14) for opt-in fail-closed LLM supervision, and configurable agent ceilings TUVL_AGENT_PAUSE_MAX_S / TUVL_AGENT_TOOL_TIMEOUT_S / TUVL_AGENT_JUDGE_TIMEOUT_S. The reserved AutonomousAgent exit aborted (raised by a supervisor/operator abort) is now documented (§4.13, §4.14) and required by Golden Rule 26; tuvl validate warns when it is unmapped and the runtime ends the run cleanly instead of raising. HITL auth.required_group is now enforced on resume (§4.10). Marketing portal (tuvl.io) and docs site (tuvl.dev) align to v2026.2.6. |
| 2026-07-02 | §2–§3 — patch 2026.2.5.1. tuvl validate now accepts the spec-wrapped Workflow / AgentModel document form (it previously read steps / context / trigger / model at the document root only, so spec-wrapped configs that ran fine failed validation) and accepts type: enum, sourcing its field-type set from the model loader so the validator and runtime can no longer drift. Engine-only; docs/portal/SDK unchanged. |
| 2026-07-01 | §4.13 — version badge 2026.2.5. BREAKING: the AutonomousAgent agent.goal field is renamed to agent.steering (no alias — the old key is ignored). New spec.supervisor live-supervision block (§4.14) that can pause / steer / abort a running agent at the iteration boundary, adding the reserved exit aborted. Per-agent agent.steering_files / agent.skills are scoped under agents/<workflow>__<stepId>/; an operator API /api/agents/* (scopes agent:observe / agent:control) + an Insight Agents dashboard observe and control live runs; tuvl keys generate mints the production Biscuit signing key. Marketing portal and docs site bump to v2026.2.5. |
| 2026-06-30 | patch 2026.2.4.1. Swagger / OpenAPI now renders a workflow's request body from its trigger.input_schema (workflow handlers read the body manually, so FastAPI had nothing to introspect); runtime input validation is unchanged. |
| 2026-06-25 | §1 — first stable release 2026.2.4. tuvl leaves beta; the 2026.2.3bN / 2026.2.4b1 pre-release line is withdrawn. BREAKING: workflow step kind: values are now PascalCase (Functional, Agent, Router, APICall, MCP, ModelOp, Response; HumanInTheLoop / AutonomousAgent unchanged) — old lowercase/snake/kebab names are rejected at load time. Marketing portal (tuvl.io) and docs site (tuvl.dev) align to v2026.2.4; the decoupled TypeScript SDK (@tuvl/client) remains on its own line. |
| 2026-06-24 | §1 — version badge bumped to 2026.2.4b1. New step kind AutonomousAgent (§4.1, §4.13): a bounded ReAct tool-loop where the model calls author-declared tools (other workflow steps) until it emits one of a declared outcome.enum, capped by max_iterations / token_budget. Adds a multi-way match: switch to kind: Router (§4.5) for data-driven branching, and Golden Rule 25. The marketing portal (tuvl.io) and docs site (tuvl.dev) bump in lockstep to v2026.2.4-beta.1; the TypeScript SDK bumps to 2026.2.4-beta.1 too, adding an agentProgress helper + AgentProgress type to surface the AutonomousAgent live loop frames. |
| 2026-06-24 | §1 — version badge bumped to 2026.2.3b5. First feature release in the b-series: (a) new Insight AI Chat assistant in the Workflow Canvas — core/dev/ai.py, exposed over gRPC as DevService.AiChat, dev-mode-only (_require_dev_key); (b) compact logfmt dev-console renderer for tuvl dev (TUVL_LOG_COLOR / NO_COLOR), JSON in production; © refreshed dev-portal model presets and clearer tenant-context / no-Redis boot logging. Coordinated: the marketing portal (tuvl.io) was rebuilt around the "agentic contract" positioning and the docs site (tuvl.dev) gained a new The Agentic Contract concept page; both bump in lockstep to v2026.2.3-beta.5. The TypeScript SDK is unaffected and stays at 2026.2.3-beta.1. |
| 2026-06-17 | §1 — version badge bumped to 2026.2.3b3. Hotfix release: (a) FastAPI session dependencies leaked IllegalStateChangeError when cancelled mid-yield (uvicorn hot-reload, client disconnect); manual try/finally lifecycle now used; (b) 52 logger call sites still used emoji + %s-format instead of structured kwargs; fully converted, and ruff rules G + LOG plus a regression test pin the standard. The marketing portal (tuvl.io) and documentation site (tuvl.dev) bump in lockstep to v2026.2.3-beta.3; the TypeScript SDK is unaffected and stays at 2026.2.3-beta.1. |
| 2026-06-16 | §1 — version badge bumped to 2026.2.3b2. Hotfix release: (a) the 2026.2.3b1 tuvl-insight wheel was published empty due to a workspace-member build-path bug — hatchling silently produced a 1 KB metadata-only wheel; (b) tuvl dev did not set TUVL_ENV=development, hitting the layer-1 production sentinel and refusing to boot. Both fixed. The marketing portal (tuvl.io) and documentation site (tuvl.dev) bump in lockstep to v2026.2.3-beta.2; the TypeScript SDK is unaffected and stays at 2026.2.3-beta.1. |
| 2026-06-15 | §1 — version badge bumped to 2026.2.3b1 for the coordinated public-beta release. The intermediate 2026.2.1-beta.2 was reserved for the TypeScript SDK already published on npm at that version, so the engine starts at 2026.2.3-beta.1 to avoid the per-registry version collision. |
| 2026-06-08 | Added --auto-login flag to tuvl dev and TUVL_DEV_AUTO_LOGIN environment variable to bypass the Insight security screen automatically. |
| 2026-06-05 | §1 — added current version badge (2026.2.1b1) and tuvl --version / -v note. |
| 2026-06-05 | §5 rule 24 — new Golden Rule: all tuvl log output is structured (structlog JSON in production, console in dev); agents should read event key + named kwargs, not parse f-string text. |
| 2026-06-01 | §3.2 — documented dev-UI multi-doc tab strip for multi-document YAML files. |
| 2026-06-01 | §4.3 — added one-node-per-file convention with rationale; file name must equal @node() argument. |
| 2026-06-01 | §5 rule 14 — added Golden Rule: one @node() per file; renumbered former 14–21 to 15–22. |
| 2026-06-01 | §6.6 — added enrich_candidate functional step to canonical workflow example. |
| 2026-06-01 | §6.7 — added nodes/ section showing two separate node files; renamed activation flow to §6.8. |
| 2026-06-01 | §2.2 — added spec.access optional block; documented default CRUD scope derivation pattern and GET /admin/scopes discovery endpoint. |
| 2026-06-01 | §5 rule 23 — new Golden Rule: every CRUD route is scope-gated by default; former 22 rules unchanged. |
| 2026-06-01 | §7 — added decision-tree entry for "Restrict who can call CRUD endpoints" → spec.access. |
End of manual.