Algen AgentRUNTIME Algen AgentRUNTIME Documentation
Algen Agent Runtime · v0.1.0a8

Build governed agents. Own the runtime.

A typed, provider-neutral Python runtime for deterministic agent execution, policy enforcement, durable state, human approvals, and observable multi-agent workflows.

0.1.0a8 · pre-alpha Python 3.12 & 3.13 Library + FastAPI service Apache-2.0

Where Runtime fits#

Runtime is the open-source execution layer of the Algen lifecycle. Projects you design in Agent Studio run on Runtime, and the same agent.yaml runs on your laptop, in CI, or behind your own API.

BuildAgent Studio→ RunAgent Runtime→ DistributeAgent Hub· GovernTraccia

One execution contract, end to end#

Runtime puts governance in the path of execution instead of around it. Every request, model call, tool operation, approval, checkpoint, and output moves through typed contracts with tenant and user context preserved.

New here? Read Install, Quickstart, and Agents & configuration first. The quickstart runs offline on a deterministic mock provider — no API keys, no network, no cost. Prefer working from your IDE's coding agent? Start with Skills & prompts.

Evaluate before production. This release is a pre-alpha, single-node foundation. Secure deployment, infrastructure hardening, and operational controls remain operator responsibilities. Start with the production checklist and the threat model.

Get started

Install#

Choose the matching release artifact or published package for the documentation version selected above. Core execution has no provider lock-in; add only the integrations your deployment needs.

zsh
$ python -m pip install --pre algen-agent-runtime

Optional extras#

ExtraAdds
postgres · redisDurable run, event, audit, approval, checkpoint, and session stores.
object-storageS3-compatible artifact byte storage.
authJWT authentication for the HTTP service.
mcpModel Context Protocol client and transports.
encryptionAES-256-GCM (cryptography) for MCP OAuth tokens kept in PostgreSQL.
encryptioncryptography: AES-256-GCM for MCP OAuth tokens kept in PostgreSQL, and Ed25519 for signed policy bundles and plugin signatures.
frameworksAll framework adapters; or pick langgraph, openai-agents, autogen, crewai.
vector-storesAll retrieval backends; or pick pgvector, chroma, faiss, qdrant.
tracciaTraccia lifecycle, identity, and governance integration.
transformersLocal Hugging Face Transformers models.
devTest, lint, and type-check tooling for contributors.

Runtime requires Python 3.12 or newer. If configuration enables an integration whose extra is missing, Runtime fails with an actionable message naming the extra to install.

Get started

Five-minute offline quickstart#

The deterministic mock provider needs no credentials, network access, or paid model calls, so the same first run works for local exploration and CI.

my-agent — quickstart.py
Explorer
my-agent .venv
import asyncio

from algen_agent_runtime.config.settings import AppSettings
from algen_agent_runtime.orchestration.container import build_container
from algen_agent_runtime.runtime.client import AlgenAgentRuntimeClient
from algen_agent_runtime.types.contracts import RunRequest


async def main() -> None:
    settings = AppSettings.model_validate({1
        "providers": {
            "mock": {"type": "mock", "default_model": "deterministic"}
        },
        "agents": [{
            "name": "hello",
            "version": "1.0.0",
            "description": "Offline quickstart agent",
            "system_instructions": "Answer concisely.",
            "default_model": {
                "name": "default",
                "provider": "mock",
                "model": "deterministic",
            },
        }],
    })
    container = build_container(settings)2
    try:
        result = await AlgenAgentRuntimeClient(container.runtime).run(3
            RunRequest(
                agent="hello", input="Hello",
                tenant_id="quickstart", user_id="local-user",
            )
        )
        print(result.output)
    finally:
        await container.aclose()4


asyncio.run(main())
ProblemsOutputTerminal
$ python quickstart.py
Hello
Python 3.12algen-agent-runtime 0.1.0a8offline · mock provider

quickstart.py in your editor (sample)

  • 1Settings — strictly validated; unknown fields fail fast.
  • 2Composition root — build_container wires strategies and adapters from settings.
  • 3Typed run — the request carries agent, input, tenant, and user through every boundary.
  • 4Lifecycle — always close the container to release stores, clients, and adapters.
  1. Define configurationDeclare a provider instance and an agent. In real projects this lives in agent.yaml — see Agents & configuration.
  2. Build the composition rootbuild_container wires runtime strategies and adapters from settings without hiding ownership.
  3. Submit a typed runrun() returns a RunResult with status, output, citations, warnings, and an execution_summary of decisions, model calls, tool calls, and usage.

Expected output: Hello — deterministic, credential-free, and suitable for an offline test.

Scaffold a project instead#

The CLI generates agent, approval, or workflow projects that run offline out of the box and import cleanly into Agent Studio.

zsh
$ algen-agent-runtime new support-desk --template workflow
# templates: agent · approval · workflow   flags: --dir PATH, --force
Get started

Architecture#

Runtime is execution as nested authority. Requests enter through the host boundary, are composed from strict configuration, and run inside a native kernel where policies, tools, approvals, and workflows sit in the path of execution. Integration, state, and evidence planes wrap the kernel without owning it.

Execution as nested authority

Domains
Layers

Interactive architecture map — follows the documentation version selected in the header

  • Click a capability to inspect its contracts, source anchor, and direct relationships, then jump to its guide.
  • Domains focus one concern, such as governance, across every layer.
  • Compare marks what was added, changed, or removed since an earlier release.
  • Full screen gives the map the whole window; press Esc to return.
Layer summary (text)
LayerOwns
01 · Host shellHTTP/SSE API, identity and scopes, API admission, streaming body limits.
02 · Composition & configurationStrict settings, the build_container composition root, scaffolding, packaging, and the Agent Manifest UI.
03 · Native execution kernelAgent state machine, policy pipeline, model router, tool executor, approvals, verification, memory, events — plus the typed workflow chamber.
04 · Integration membraneProvider instances, starter tools, framework adapters, MCP, safe outbound networking, and model services.
05 · State & knowledgeDurable stores, scoped cache, retrieval, and artifacts.
06 · Analytical executionAnalytical DAGs, query governance, and the experimental work queue.
07 · Evidence & operationsOpenTelemetry signals and the optional Traccia integration.
Get started

What's new#

Use the version selector in the header to read the documentation for a specific pre-release. Entries are taken from CHANGELOG.md.

Release history#


Build

Agents & configuration#

An agent is a versioned definition: a default model plus instructions, tools, policies, and limits. Agents, providers, workflows, stores, and telemetry are all declared in one strictly validated settings document — usually agent.yaml.

support-desk — agent.yaml
Explorer
support-desk
providers:
  local:1
    type: mock
    default_model: deterministic

agents:
  - name: support-assistant
    version: 1.0.0
    description: Answers help-center questions
    system_instructions: Answer only from cited help-center articles.
    default_model:
      name: default
      provider: local2
      model: deterministic
    planning_strategy: react
    max_steps: 83
    guardrail_policy:
      policies: [secrets, pii, prompt_injection, authorization]
    budget:
      max_tokens: 20000
      max_cost_usd: 0.50
      max_latency_seconds: 60

telemetry: {enabled: false}
YAMLschema: AppSettings✓ valid

agent.yaml and app.py (sample) — click the file tabs to switch

  • 1Provider instance — a named deployment of an adapter type; several can share one type.
  • 2Reference — must resolve to a configured provider instance at load time.
  • 3Limits — step count, policies, and budget bound every run.
  • 4Load — load_settings merges files and environment into one validated object.

Configuration precedence#

Settings merge in a deliberate order. Later sources override earlier ones; request overrides must be explicitly allowlisted by operators.

Built-in defaults→ YAML files→ ALGEN_AGENT_RUNTIME__ env→ Deployment overrides

Never put a secret in YAML. Use an env:// reference such as env://OPENAI_API_KEY; Runtime resolves it from the environment at the boundary that needs it.

Supported hosted and local providers, capability narrowing, and fallback chains are covered in Providers and Provider compatibility.

Gemini, xAI, Groq and ordered fallback#

From 0.1.0a18 gemini (Google Gemini API), xai (Grok) and groq are provider types, built on the OpenAI-compatible adapter with the vendor base URL and a default secret (env://GEMINI_API_KEY, env://XAI_API_KEY, env://GROQ_API_KEY). base_url and capabilities can be set per entry. Gemini supports embeddings (gemini-embedding-001 by default) and image input; Groq reports strict json_schema support only for gpt-oss models.

agent.yaml · ordered fallback
providers:
  gemini: {type: gemini, default_model: gemini-flash-latest}   # env://GEMINI_API_KEY
  groq: {type: groq, default_model: openai/gpt-oss-120b}       # env://GROQ_API_KEY
agents:
  - name: support-assistant
    default_model: {name: primary, provider: gemini, model: gemini-flash-latest, routing_preference: ordered}
    fallback_models:
      - {name: fast, provider: groq, model: openai/gpt-oss-120b, routing_preference: ordered}

By default (routing_preference: balanced) candidates are ranked by quality tier, observed latency and cost. ordered tries the default model, then each fallback, in declaration order; providers with an open circuit breaker, missing capabilities or exceeded latency or cost limits are still skipped. It describes the agent's whole model list, so configuration that sets it on only some profiles is rejected.

Behaviour change in 0.1.0a18. The router no longer falls back when a provider rejects the request as invalid (HTTP 400 or 422, invalid_request): it raises that error, which does not count against the provider's circuit breaker. Authentication errors, rate limits, timeouts (HTTP 408 is now a retryable timeout), 5xx and other 4xx such as 404 still fall back. ProviderError.status_code carries the provider's HTTP status.

From 0.1.0a21 a model's malformed tool call (arguments that are not valid JSON, not an object, or cut off by the output-token limit) is a retryable InvalidToolCallError from the Anthropic and OpenAI-compatible adapters, not a failed run. The run retries the model call within its retry_policy; a call cut off by the limit is retried with a higher one. The error names the tool and the parse error, never the arguments. Each is recorded as a model.tool_call_invalid audit event and does not count against the provider's circuit breaker.

Runtime a7 · context engineering

Context & memory#

Simple agents use bounded context assembly. Multi-step agents can add recoverable summaries, artifact read-back, structured progress, selected skill guidance, and learned memory. Each capability is explicit and governed; a summary or remembered procedure never grants authorization.

New in 0.1.0a7. This section documents the published 0.1.0a7 package. Install it with python -m pip install "algen-agent-runtime==0.1.0a7"; Studio now requires Runtime 0.1.0a8 or later.

Simple agents#

The built-in default builder allows 16,000 estimated message tokens. It loads session history once and protects system instructions, the latest user request, and the newest complete message group. Tool calls and results stay together. A required input that cannot fit fails with BudgetExceededError; orphan tool results fail with ConflictError. Disabled session memory or a zero item limit prevents loading prior turns.

Set a model profile's context_window_tokens and a positive output cap to reserve output, tool definitions, response schemas, profile extensions, and safety headroom before dispatch. Each fallback packs from the original request. Estimates cover normalized text, not a provider tokenizer or image/file costs. Profiles without a configured window retain their previous routing behavior.

Multi-step configuration#

This complete offline manifest enables all context capabilities for demonstration. Default write and destructive-action approvals remain active. Choose only the capabilities and permissions your application needs.

agent.yaml · context capabilities
providers:
  mock: {type: mock, default_model: deterministic}
agents:
  - name: context-demo
    version: 1.0.0
    description: Governed context for a multi-step agent
    system_instructions: Verify evidence and keep concise task progress.
    default_model:
      name: bounded
      provider: mock
      model: deterministic
      context_window_tokens: 16000
      context_safety_margin_tokens: 256
    budget:
      max_output_tokens: 2048
    enabled_tools:
      - core.context_read
      - core.working_state_update
      - core.skills_load
      - core.memory_recall
      - core.memory_write
      - core.memory_delete
    tool_permissions: [context.read, context.write, skills.read,
                       memory.read, memory.write, memory.delete]
    context_policy:
      offload_tool_results: true
      compact_history: true
      archive_compacted_history: true
      compaction_trigger_tokens: 12000
      compaction_trigger_ratio: 0.8
      compaction_keep_recent_groups: 6
      compaction_max_input_tokens: 8000
      compaction_max_output_tokens: 1024
      artifact_retention_seconds: 86400
      working_state_enabled: true
      skills_enabled: true
    skills:
      - name: reporting
        version: 1.0.0
        description: Prepare an evidence-based report.
        instructions: Check sources, compare totals, and list open questions.
    learned_memory_policy:
      enabled: true
      max_recall_tokens: 2048
      max_retention_seconds: 2592000

Configuration defaults#

ScopeSettingDefaultBehavior
Model profilecontext_window_tokensnullNo model-window packing until configured; no capacity is guessed.
Model profilecontext_safety_margin_tokens256Reserved with output, tool/schema, and extension overhead.
Contextoffload_tool_results / compact_history / archive_compacted_historyfalseEnable separately; archival requires compaction and the scoped reader.
Contexttool_result_max_inline_bytes / tool_result_preview_chars16384 / 256Processed results become bounded references above the byte limit.
Contextartifact_retention_seconds86400Retention starts at run creation, including paused time.
Contextcompaction_trigger_tokens / compaction_trigger_ratio12000 / 0.8Smaller of the manual trigger and adaptive allowance; null ratio restores the fixed trigger.
Contextcompaction_keep_recent_groups6Complete recent message groups remain verbatim.
Contextcompaction_max_input_tokens / compaction_max_output_tokens8000 / 1024Summary calls also obey model and cumulative run budgets.
Contextworking_state_enabled / working_state_max_tokensfalse / 2048Protected run-local goal, decisions, constraints, completed work, and open tasks.
Contextskills_enabledfalseAdvertise descriptions; explicitly load up to four configured skills.
Contextskill_catalog_max_tokens / loaded_skills_max_tokens2048 / 4096Separate budgets for the catalog and selected guidance.
Learned memoryenabled / max_recall_tokens / max_retention_secondsfalse / 2048 / 2592000Explicit cross-run recall; memory store defaults to ephemeral memory.
Contextshorten_older_tool_results / shortened_tool_result_charsfalse / 1200Older tool exchanges that no longer fit keep their text results cut to the opening instead of being dropped (64 to 16384 characters).

Packing, compaction & recovery#

core.context_read requires context.read and returns at most 4,096 Unicode characters per page using artifact_id, offset, and limit. Follow next_offset until null. Reads and replays check tenant, originating run/user, checksum, purpose, availability, and expiry. New opaque ctx_ IDs avoid incidental numeric PII redaction; existing UUID references still work.

Compaction selects at most one bounded prefix per context build. The effective trigger uses the smallest configured builder/allowed-model allowance after reservations; retrieval reserves evidence space, and working state/skill projections count toward the trigger. The recent-group floor and indivisible tool exchanges can prevent compaction. Summary calls use normal policies, allowlists, deadlines, and the same run token/cost budget. Empty, truncated, oversized, tool-calling, or non-reducing summaries fail without advancing the cursor.

By default, canonical history remains inline. archive_compacted_history: true keeps an absolute context_history_offset, protected context_history_pinned messages, the summary, and the remaining tail. Sources move into a linked archive chain before the smaller checkpoint commits. This reduces historical payload duplication; protected inputs, the uncompacted tail, and artifact references may still grow.

Retention determines recoverability. Archived sources can expire, be deleted, or be quarantined while their run and summary survive. Choose durable artifact storage and sufficient retention for pauses and audits. Memory storage is ephemeral. Runtime never bypasses lifecycle or authorization checks to rebuild deleted sources.

Shortened older tool results#

Packing keeps the newest messages that fit max_estimated_tokens and drops older tool exchanges whole, so on a long run the model forgets what it already looked up and may call the same tools again. From 0.1.0a14 an agent can keep those exchanges in shortened form instead. It is off by default.

agent.yaml · context_policy
context_policy:
  shorten_older_tool_results: true
  shortened_tool_result_chars: 1200   # 64 to 16384

When an older exchange no longer fits, each text result longer than shortened_tool_result_chars is cut to that length and ends with a note that it was shortened and already retrieved. The call stays with its results, so the exchange is valid for every provider, and packing goes on with older exchanges while the shortened versions fit. Recent results, required messages and non-text blocks are never changed, and nothing is rewritten in the run history: only the request sent to the model changes. The default and retrieval builders honour it, alongside offload_tool_results and compact_history.

The final packing does not shorten. When a model profile sets context_window_tokens, the request is packed once more against that window just before the call, and that packing drops older exchanges whole. Set the builder budget at or below the model's message allowance so it has nothing left to drop. Builders that override pack(self, messages) keep working: build() passes the policy only when shortening is on.

Working state & progressive skills#

core.working_state_update requires context.write and replaces the five-field record using expected_revision. It follows write approvals and memory-write policies; stale revisions return a small conflict receipt. The bounded assistant projection survives summaries but remains untrusted notes. New runs start empty.

core.skills_load requires skills.read. Its names array replaces the selected guidance (up to four distinct names); an empty array clears it. Configured AgentDefinition.skills contain a name, version, description, instructions, and optional required permissions. Descriptions are visible first; selected bodies are loaded only after authorization and policy checks. Recovery rechecks identity, version, source hash, and permissions.

These are Runtime instruction records, separate from the IDE SKILL.md packs described in Skills & prompts. No filesystem discovery, executable installation, or permission grant occurs. Built-in compaction, working state, and skills require the default or retrieval builder; custom builders keep their own behavior.

Learned memory across runs#

core.memory_recall, core.memory_write, and core.memory_delete require memory.read, memory.write, and memory.delete respectively. Records are scoped to tenant, user, and logical agent name; versions share that namespace. Writes and deletes follow approvals and expected-revision checks. Atomic operation receipts prevent interrupted retries from resurrecting corrected or deleted content.

Recall is explicit, bounded, and based on an exact key or literal case-insensitive substring search. There is no automatic extraction, semantic ranking, injection into every prompt, or conversion of remembered procedures into executable skills. Default writes request one day, capped by the agent's retention policy and anchored to source-run start. Recall/replay checks current revisions and expiry.

For durable storage, install the postgres extra and set storage.learned_memory_store: postgres with storage.postgres_dsn: env://ALGEN_POSTGRES_DSN. Migration 011_learned_memory.sql adds records and replay receipts independently of source-run retention. Maintenance clears expired payloads; content-free revision tombstones and operation receipts currently have no automatic garbage collection.

Evaluations & diagnostics#

Use ContextEvaluationCase, ContextEvaluationArtifact, and ContextEvaluationRunner from algen_agent_runtime.evaluation to check exact message retention, required/forbidden text, estimated token and checkpoint-byte limits, complete tool exchanges, and ordered source recovery. Missing measurements and failed cases block promotion; reports omit source text and exception payloads. Lexical checks and scripted fixtures do not establish real-model factual quality.

python -m examples.context_evaluation
python -m examples.context_compaction
python -m examples.context_offloading
python -m examples.working_state
python -m examples.progressive_skills
python -m examples.learned_memory

context.retrieved adds estimates for total, summary, working-state, catalog, and loaded-skill tokens plus retained/archived history counts. context.compaction.assessed explains planning with fixed reasons such as below_trigger, recent_groups_only, pending_tools, input_limit, or ready. Ready is not a commit receipt: context.compacted follows successful persistence and includes before/after history estimates. New diagnostics are content-free and are distinct from optional model-content telemetry.

a7 upgrade checklist#

  • Upgrade strict manifest, checkpoint, and event readers together, including workers. New defaulted fields and event types are incompatible with older strict readers.
  • Run the learned-memory migration when using PostgreSQL. Context checkpoint fields themselves require no SQL migration; old checkpoints can recover only their surviving messages.
  • Use RunState.append_message() for execution history. Direct edits to packed messages do not append to canonical history.
  • Review reduced payload limits and earlier eviction from serialized-message estimates. Required oversized input now fails explicitly.
  • Verify the four offline context scenarios, application-specific summary quality, artifact retention, and recovery before enabling optional features.

Detailed contracts: compaction and artifacts, working state, progressive skills, learned memory, and evaluations and diagnostics.

Build

Typed tools#

Every callable becomes a typed ToolDefinition. The governed executor validates arguments, checks permissions, reserves idempotency, handles approval, records outcomes, and redacts results — the model never calls your function directly.

support-desk — plugins.py
Explorer
support-desk agent.yaml
from typing import Any

from algen_agent_runtime.plugins.sdk import PluginManifest, plugin
from algen_agent_runtime.tools.contracts import SideEffect, Tool, ToolContext, ToolDefinition


@plugin(PluginManifest(name="example.utilities", version="1.0.0", publisher="example"))
class UtilitiesPlugin:
    def register(self, context: Any) -> None:
        async def word_count(arguments: dict[str, Any], tool_context: ToolContext) -> dict[str, int]:4
            return {"count": len(arguments["text"].split())}

        context.tools.register(
            Tool(
                ToolDefinition(
                    name="text.word_count",1
                    version="1.0.0",
                    description="Count whitespace-separated words.",
                    input_schema={2
                        "type": "object",
                        "properties": {"text": {"type": "string"}},
                        "required": ["text"],
                        "additionalProperties": False,
                    },
                    output_schema={
                        "type": "object",
                        "properties": {"count": {"type": "integer"}},
                        "required": ["count"],
                    },
                    side_effect=SideEffect.NONE,3
                ),
                word_count,
            )
        )
Python 3.12examples/plugins.pyside_effect: none

A read-only tool registered from a plugin (from examples/plugins.py)

  • 1Identity — a lowercase, namespaced name plus a version.
  • 2Schemas — arguments are validated before execution; results against the output schema.
  • 3Classification — decides approval, caching, and recovery posture.
  • 4Handler — async; receives validated arguments and a ToolContext.

What the executor does#

Validate args→Check permissions→Reserve idempotency→Approval→Execute→Record & redact

Side effects & idempotency#

SideEffectMeaning
NONE · READNo externally visible mutation. May be cacheable under explicit policy.
WRITE · EXTERNALChanges state locally or in another system. Requires approval when the agent's approval_policy.require_for_side_effects is on (the default). Never response-cached.
DESTRUCTIVEIrreversible change. Requires approval by default through require_for_destructive.
IdempotencyExecution posture
IDEMPOTENTRepeated execution has the same effect; safe to replay.
KEYEDUses a deterministic reservation key for duplicate-safe replay.
NON_IDEMPOTENTPersisted before execution; an interrupted call is marked indeterminate for reconciliation, never blindly retried.

From 0.1.0a17 an operator settles a call whose outcome is unknown: lists it, records what happened, asks the tool, or undoes it. A tool can answer for itself with a reconcile hook, and an agent can wait for the operator instead of failing. See Side-effect operations.

Enable a registered tool on an agent with enabled_tools, and gate it with required_permissions on the definition. For ready-made, bounded utilities see the opt-in starter tool pack.

Also in 0.1.0a19: ToolContext.session_id (also declarable in cache_context); a tool-result cache with scope: session works instead of failing every cacheable call; Ollama replies no longer carry a model's private reasoning ending in </think>. The document extractors in algen_agent_runtime.security are deprecated and warn on import: use algen_agent_tools.documents (algen-agent-tools 0.2.0a7 or later), which DOCX and PPTX with images now report as review.

Runtime a8 · agent tooling

Tool behaviour & HTTP tools#

Agents that work through many tool calls, such as coding helpers, need to recover from a failed call, accept a declined action, and call APIs without custom code. Each behaviour is opt-in on the agent definition; defaults keep the earlier fail-the-run behaviour.

New in 0.1.0a8. This section documents the published 0.1.0a8 package. Install it with python -m pip install "algen-agent-runtime==0.1.0a8"; Studio requires Runtime 0.1.0a8 or later.

Agent tool policy#

FieldDefaultEffect
tool_policy.on_errorfail_runreturn_to_model: a failed call (unknown or disabled tool, invalid arguments, guardrail denial, tool exception) becomes the call's result, {"error": {"type", "message"}}, and the run continues. The tool's own exception text is never shown.
tool_policy.on_indeterminateon_errorpause: a side-effecting call whose outcome is unknown pauses the run in awaiting_reconciliation until an operator settles it; the run then continues with the recorded result, or a tool error when the call did not take effect or was undone. See Side-effect operations.
tool_policy.parallel_calls · max_parallel_callsfalse · 4Runs the tool calls of one model reply concurrently and records them in the order asked. A reply with a call that needs approval, or a memory, skill, or working-state tool, runs one call at a time.
approval_policy.on_rejectionfail_runcontinue: a rejected approval answers the call with {"declined": true, "message", "reason"?} and the run continues. Pass reason beside the rejected decision.
guardrail_policy.tool_policiesthe agent's policiesPolicies applied to tool arguments and results.
guardrail_policy.tool_result_injectionoffflag or block tool output that tries to instruct the model.
memory_policy.store_tool_payloads · tool_payload_max_charsoffKeeps a turn's tool calls and results in session memory, each capped in size.

Behaviour change. An agent's guardrail_policy.policies now also apply at before_tool and after_tool; previously every registered policy ran there. Agents with the default policy list are unaffected.

Behaviour change in 0.1.0a14: schema check before approval. A call whose arguments do not fit the tool's input_schema is refused before it runs or any approval is created. With on_error: return_to_model the model gets {"ok": false, "error": "Invalid arguments for <tool>: ...", "note": ...} with status refused, instead of a ToolExecutionError; a fail_run agent still fails, without first asking someone to approve a call that cannot run. See Tool-call guards.

From 0.1.0a18 a tool can also report a failure it returns as data, so the model can correct itself without the run failing; see Failures as data.

Declarative HTTP API tools#

http_tools turns an HTTP API operation into a tool. Path placeholders must be required parameters and the host cannot be templated. Credentials are env:// references resolved per call. Requests use the SSRF-safe client without redirects, and settings validation refuses a tool whose host is not in security.allowed_http_hosts. GET is a read; other methods are external side effects that need approval under the default policy. A non-2xx response returns ok: false to the model instead of failing.

agent.yaml · HTTP API tool
security:
  allowed_http_hosts: ["api.github.com"]
http_tools:
  - name: github.issue
    description: Read one GitHub issue.
    method: GET
    url: https://api.github.com/repos/{owner}/{repo}/issues/{number}
    parameters:
      type: object
      properties:
        owner: {type: string}
        repo: {type: string}
        number: {type: integer}
      required: [owner, repo, number]
    auth: {type: bearer, token: env://GITHUB_TOKEN}
    response: {select: /title}

Authentication is none, bearer, basic, or header. Pagination follows link_header or increments a page_param, up to max_pages. timeout_seconds and max_result_bytes bound each request. The tool returns {"ok", "status", "headers", "data", "pages"}. See HTTP API tools and agent tool behaviour.

Authentication is none, bearer, basic, header, or caller (the signed-in user's credential, below). Pagination follows link_header or increments a page_param, up to max_pages. timeout_seconds and max_result_bytes bound each request. The tool returns {"ok", "status", "headers", "data", "pages"}. See HTTP API tools and agent tool behaviour.

Calling an API as the signed-in user#

From 0.1.0a18 auth.type: caller sends the credential the host passed for this run (credentials of the caller) instead of one deployment secret, so the API applies that user's permissions.

agent.yaml · auth.type: caller
http_tools:
  - name: lms.my_courses
    description: List the courses the signed-in user is enrolled in.
    url: https://lms.example.com/api/me/courses
    auth:
      type: caller
      credential: lms_token       # key in the run's credentials (default: authorization)
      # header_name: Authorization  (default)
      # scheme: Bearer              (optional prefix)
  • One key per API. Forward a token only to an API that is its audience. Any caller tool can name any key in the run's credentials, so which API receives which token is decided by this configuration alone.
  • Fails closed. A missing or blank credential, or one that is only the scheme, fails with authentication required before any request, and the ledger records the call failed, not indeterminate. That is the case for runs started through the HTTP API, workflow tool steps, and runs resumed or recovered without credentials. A value with another scheme or a control character is refused without being echoed.
  • Declared origin only. The credential goes only to the URL's scheme, host and port; link-header pagination to another origin fails the call. Cookie, Host, Proxy-Authorization, hop-by-hop and framing headers are refused as header_name, headers may not set the auth header, and token, username and password are refused with caller.
  • Never cached. A call whose context carries caller credentials is never served from, or stored in, the tool-result cache.

Also in 0.1.0a18: a path value that is empty, . or .. (also percent-encoded) is refused, so a model argument cannot move the call, and a forwarded credential, to another path on the same host; and a query string in the url template is merged with the query templates and pagination parameters instead of being dropped. See HTTP API tools.

Workflow tool steps#

A workflow handler node can call any registered tool, including an HTTP API tool, with handler: tool:<name> and an arguments map. The call goes through the same permissions, approval, and idempotency checks as an agent's tool call, and its result is stored under the node's output_key.

Runtime a14 · tool-call guards

Tool-call guards#

Guards keep a model from wasting a run on calls that cannot succeed, on reads it already made, or on tools when it should be answering. A refused or held-back call never runs; it still answers the model's tool call, with a note saying what to do instead. Their state (tool_refusals, tool_call_counts, seen_read_calls) is kept in RunState, so it survives an approval pause and a restart, and a checkpoint without it still loads. Except for the schema check, every guard is off by default.

New in 0.1.0a14. Install it with python -m pip install "algen-agent-runtime==0.1.0a14". Studio's Runtime floor is still 0.1.0a13, so these settings take effect only where 0.1.0a14 or later is installed.

Configure#

agent.yaml · tool-call guards
agents:
  - name: people-os.assistant
    # ...
    max_steps: 30
    enabled_tools: [hr.find_people, hr.update_record, jobs.status]
    tool_policy:
      on_error: return_to_model
      max_refusals_per_tool: 3      # from the third refusal: stop and tell the user
      repeat_guard: true            # an identical read with nothing changed returns a note
      max_calls_per_tool:
        hr.find_people: 5           # a search the model could keep rephrasing
    wrap_up_policy:
      enabled: true
      tool_calls_left: 2            # warn while two or fewer tool calls are affordable
      token_wrap_up_share: 0.75     # from 75% of the token budget: at most two more calls
      token_answer_share: 0.9       # from 90%: answer now
http_tools:
  - name: jobs.status
    # ...
    volatile: true                  # the status changes on its own: never a repeat
SettingDefaultEffect
Schema checkalways onArguments are checked against the tool's input_schema before the call runs or is put to the user, for every tool (tool.schema_invalid).
Tool(..., precheck=...) · @tool(..., precheck=...)noneRuns for every call that is not read-only, before any approval. Returns why the change would be refused, or None.
tool_policy.max_refusals_per_toolnullFrom this many refusals of one tool in a run (schema or precheck), the note tells the model to stop calling it and tell the user what was refused and why. Unset, it always says to fix the call and try again.
tool_policy.max_refusals_per_toolnullFrom this many refusals of one tool in a row (schema or precheck), the note tells the model to stop calling it and tell the user what was refused and why. A call to the tool that runs successfully starts the count again. Unset, it always says to fix the call and try again.
tool_policy.refusal_countconsecutiveper_run: count every refusal of the tool in the run, as 0.1.0a17 did. A call that fails, is held back by another guard or returns a failure as data does not reset the count.
tool_policy.repeat_guardfalseA read identical to one that already succeeded, with no successful change since, returns a note instead of running (status repeated).
tool_policy.max_calls_per_tool{}The most times each named tool runs in a run; past it the call returns a note to answer from what it already returned (status over_limit). Refused calls do not count.
volatilefalseOn ToolDefinition, @tool(volatile=True) and http_tools[]: a read that is never held back. The live starter.clock sets it.
wrap_up_policy.enabledfalseNear the end of the run's steps or token budget, each tool message carries a wrap-up notice as a separate text block.
tool_calls_left · token_wrap_up_share · token_answer_share2 · 0.75 · 0.9Warn at or below this many affordable tool calls; from this share of the token budget at most tool_calls_left more; from this share, none.

Checks before a side effect#

A precheck refuses a change before it runs or anyone is asked to approve it. The Runtime calls it for every call whose side effect is not none or read, whether or not the call needs approval; a call already approved is not prechecked again.

plugin.py · precheck
async def config_precheck(
    definition: ToolDefinition, arguments: Mapping[str, Any], earlier: Sequence[EarlierCall]
) -> str | None:
    """Why the change would be refused, or None. ``earlier`` holds the calls planned
    before this one in the same model reply, as (definition, arguments)."""
    ...

Tool(definition, handler, precheck=config_precheck)
# or: @tool(write(approval=True), precheck=config_precheck)

earlier is filled only when a reply's calls run together (parallel_calls); otherwise the earlier calls have already run, one at a time, and the precheck sees their effect. A refused call is answered with {"ok": false, "error": <problem>, "note": <note>} and status refused: nothing ran and nobody was asked.

Repeats, limits and room to answer#

With repeat_guard, a successful write, external or destructive call lets every read run again, and a failed read is forgotten, so its retry runs. This is separate from the run-scoped tool cache: the cache hides a repeat from the model, the guard tells the model it is repeating. An answer takes two steps and each further tool call two more; with wrap_up_policy on, the Runtime computes the calls still affordable after each tool call and adds the notice at or below tool_calls_left, whatever the result's type. There is no notice while continuation_policy still has automatic extensions left.

Events, audit and spans#

GuardRun eventtool.completed statusAudit (action, outcome)
Schema checktool.refused (tool.schema_invalid)refusedtool.call, refused
Prechecktool.refused (tool.precheck_refused)refusedtool.call, refused
Repeat guardtool.repeated (tool.repeated)repeatedtool.call, repeated
Call limittool.repeated (tool.call_limit)over_limittool.call, over_limit
Wrap-up noticetool.wrap_up (tool_calls_left, reason)unchangedrun.wrap_up, notified

Each guarded call also has an agent.tool.call span with its tool.status and tool.guard.* attributes, and the wrap-up notice sets agent.wrap_up.tool_calls_left. None of them carries argument values. Alert on runs whose refusal_limit_reached is true or with many tool.repeated events: the model is stuck on a tool's contract or its instructions. See Tool-call guards and Operations.

Behaviour changes in 0.1.0a18. max_refusals_per_tool counts refusals in a row (set refusal_count: per_run to keep the 0.1.0a17 count). A call a guard refuses or holds back now emits tool.started with the guard's status before its tool.refused or tool.repeated, and its tool.completed carries reason_code. In a parallel reply, every call's step.started and tool.started are emitted in the order the model sent the calls, before any of them runs.

Upgrading from the Algen Agent Harness#

  • prechecks.py → Tool.precheck, the built-in schema check and tool_policy.max_refusals_per_tool: 3 (the harness's MAX_REFUSED). The planned-call bookkeeping and the serial lock are not needed: calls that need approval run one at a time, in the model's order, and a parallel reply's earlier calls arrive as earlier.
  • policies.py ApprovalRules → approval_policy and ToolDefinition.requires_approval (or write(approval=True)).
  • repeats.py → repeat_guard, max_calls_per_tool (was turn_call_limits) and volatile (was repeatable_tools).
  • allowance.py → wrap_up_policy; the notice is a second text block on the tool message, not keys merged into a dict result.
  • metering.py → a UsageMeter passed to build_container; see Usage metering. Counts are per run, which is one turn of a Studio conversation.
Runtime a11 · code components

Plugins, tools & project validation#

Every component an agent names now has a declared source. Code components are registered by a plugin that agent.yaml names, so describe can list them without running them, check can validate a project the way Studio will, and the build fails on a name nothing registered. Tools are written as typed functions with a policy preset.

In Studio. Studio validates every name an agent uses against what the project's container registers: the Runtime's own components and the manifest's in Studio, a declared plugin's from the inventory the managed build describes. Until a build has described the plugins, their names wait as COMPONENT_PENDING_BUILD; a project with code that registers components outside a plugin gets UNDECLARED_CODE_COMPONENT.

Declare a plugin#

A domain_plugins entry is module:attribute or an entry point in the algen_agent_runtime.plugins group, and loads only from security.trusted_plugin_prefixes, checked on module boundaries before anything is imported. A plugin registers tools, planners, context builders, verifiers, response composers, model providers, MCP servers, semantic layers, analytical methods and model services; a name that is already taken raises ConflictError, so a plugin cannot replace a built-in.

agent.yaml · a declared plugin
security:
  trusted_plugin_prefixes: [crm_agent]
agents:
  - name: crm-assistant
    enabled_tools: [crm.find_account, crm.update_owner]
    tool_permissions: [crm.read, crm.write]
    domain_plugins: ["crm_agent.plugin:CrmPlugin"]
crm_agent/plugin.py · a toolkit and its plugin
crm = Toolkit("crm", env=("CRM_API_TOKEN",), client=open_crm)


@crm.tool(read())
async def find_account(domain: str) -> Account:
    """Find one account by its web domain."""
    return await (await crm.client()).find(domain)


@crm.tool(write(approval=True))
async def update_owner(account_id: str, owner: str, context: ToolContext) -> Receipt:
    """Change an account's owner."""
    return await (await crm.client()).set_owner(account_id, owner, key=context.idempotency_key)


@plugin(PluginManifest(name="crm-agent.tools", version="1.0.0", publisher="acme"))
class CrmPlugin:
    def register(self, context):
        crm.register(context)

From 0.1.0a18 a plugin package can also be signed, and verified before it is imported; see Plugin signing.

A plugin only constructs and registers in register; a toolkit's client opens at container start or on first use, with env:// secrets, and closes with the container. build_container(additional_tools=...) keeps working for tests, but Studio cannot see it.

Tool presets#

@tool and Toolkit.tool build strict schemas from type hints or Pydantic models and take the description from the docstring. A preset sets the governance fields together; a change is keyed by the idempotency key and never retried.

PresetSide effectPermissionAttemptsApproval
read()read<prefix>.readdefault (3)agent policy
write(approval=...)write<prefix>.write1True: always · handler: deployment flow
external(approval=...)external<prefix>.write1as write
destructive()destructive<prefix>.write1always, by default
long_running(base)the base'sthe base's1the base's · 180 s timeout

assert_tool_contract and fake_tool_context test a tool offline. algen-agent-runtime new scaffolds a plugin with a toolkit, and examples/quickstart_agent_tool is a complete project.

Failures as data, cache context and notes#

crm_agent/plugin.py · 0.1.0a18 tool options
@crm.tool(write(), failure_field="ok")
async def change_setting(name: str, value: str) -> ChangeReceipt:
    """Change a setting; {"ok": false, "error": ...} when the name is not a setting."""
    ...


@crm.tool(read(), cache_context={"user_id"})
async def my_open_orders(context: ToolContext) -> list[Order]:
    """List the signed-in user's open orders."""
    await context.emit_note(title="Looked up open orders", suggestions=["Show overdue only"])
    ...
  • A failure the model should fix. failure_field="ok" (on @tool, Toolkit.tool, build_tool or ToolDefinition) marks a result whose field is exactly false as failed; a handler can also return ToolResult(value=..., failed=True). The call is recorded failed in tool.completed, the audit record and the span, while the payload still goes to the model whatever on_error says. It is not retried or cached, does not reset the refusal count or feed the repeat guard, and the ledger records it completed with result.failed, so a replay returns it without running the call again.
  • cache_context. A tool names the ToolContext values its result depends on (tenant_id, user_id, run_id, step_id); the tool-result cache adds them, digested, to the key, so one user is never served another's result. Tools that declare nothing keep their cache keys.
  • Notes for the interface. context.emit_note(title=, paths=, suggestions=, data=) writes a bounded tool.note run event for the call that never reaches the model. Anyone who can read the run's events sees it: no secrets.
  • Refusing before contacting anything. Raise ToolNotAttemptedError (a ToolExecutionError); the ledger records the call failed, not indeterminate, even for a write.
  • Middleware. Tool.with_handler(handler) returns a copy with a new handler, keeping its class, definition and every hook; tools.contracts.wrap(tool, middleware) is shorthand for it.

Workflow nodes, declared HTTP tools and MCP tools (isError) do not return a failure as data yet. examples/pattern_guarded_tools shows each option. See Tool authoring.

Credentials of the caller#

From 0.1.0a15 a tool that acts as the signed-in user, for example by calling the host's own API, reads the user's credentials from context.credentials. The host passes them per run, as the keyword-only credentials= of Runtime.start, run and resume; the Runtime protocol has the same keyword, so a host's own implementation should accept it.

host · pass the caller's credentials
result = await runtime.run(request, credentials={"authorization": request_headers["authorization"]})
# After an approval or clarification pause, supply them again: none are kept.
await runtime.resume(run_id, tenant_id, {"decision": "approved"}, credentials=fresh_credentials)
crm_agent/plugin.py · a tool that acts as the caller
@crm.tool(read())
async def my_open_tickets(context: ToolContext) -> list[Ticket]:
    """List the open tickets assigned to the signed-in user."""
    authorization = context.credentials.get("authorization")
    if authorization is None:
        raise ToolExecutionError("authentication required: sign in again and retry")
    client = await crm.client()
    return await client.tickets(assignee="me", headers={"Authorization": authorization})
  • context.credentials is a read-only Mapping[str, str], empty by default: a private copy of what the host passed, so changing the host's dict afterwards changes nothing.
  • Memory only, for the run's current execution. They are never written to run state or checkpoints, events, audits, spans, logs, caches or the model's context, and are left out of ToolContext's repr, model_dump, equality and hashing. Do not put them in a tool's result or error message.
  • They are dropped when the run pauses for approval or clarification and when it ends. A resumed run sees only the credentials passed to resume; resumed without any, or recovered by Runtime.recover() after a restart, it sees an empty mapping. Answer that with an “authentication required” error rather than acting without the user's authority.
  • All tool calls of a run, parallel ones included, see the same credentials; concurrent runs never see each other's. core.subagent passes the parent's credentials to its child run; a host's own delegation tool should call runtime.run(child_request, credentials=context.credentials).
  • ToolContext.secrets is different: static deployment configuration, the same for every run. In tests, fake_tool_context(credentials=...) sets the mapping.

Known limitations. Workflow tool steps, and the agent runs a workflow starts, get no credentials, and the HTTP API's run and resume endpoints forward none: a host that needs them calls the Runtime through the Python API. A run recovered after a restart has none. A child run inherits them only through core.subagent. See Credentials of the caller.

Startup validation, describe and validate#

build_container fails when an agent's enabled_tools, planning_strategy, response_composer, verification_policy.verifiers, context_builder or model provider names something nothing registered, and suggests the closest name. MCP tools are checked when their server connects. runtime.component_check: warn logs instead while a project moves to a plugin.

terminal · before import
algen-agent-runtime describe --config agent.yaml --path src --json
algen-agent-runtime check agent.yaml --path src
Issue codeSeverityMeaning
UNKNOWN_COMPONENTErrorAn agent names something nothing registered; the report carries the closest name.
PLUGIN_ERROR · COMPONENT_CONFLICTErrorA plugin is untrusted, missing or invalid, or two sources register one name.
CONFIGURATION_ERROR · UNSUPPORTED_PROVIDER_TYPEErrorThe settings do not load, or a provider type is not one the Runtime builds.
UNVERIFIED_COMPONENTWarningWith --profile standalone: a name only the project's own code registers, which build_container checks at startup.
OWN_CONTAINERWarningThe project runs its own entry point, server or workers; Studio does not.
CONSTRUCTOR_COMPONENTS · POST_BUILD_REGISTRATIONWarningComponents passed to build_container or registered on the built container; move them to a plugin.
DIRECT_ENV_READ · CODE_DEFINED_AGENTWarningVariables read outside env://, so a deploy never asks for them; agents defined in code, which Studio cannot see.

Ready-made connectors. The algen-agent-tools package ships PostgreSQL, SMTP and Gmail toolkits and MCP governance overlays as declared plugins under the prefix algen_agent_tools. See the Tools documentation.

In Studio#

Studio validates the same names without importing project code. It describes the Runtime's and the manifest's components itself; for declared plugins, each managed build runs describe --json in the build sandbox (no network, read-only, unprivileged) and keeps the result with the build, for every revision on the same code that declares the same plugins. The overview's Code and Studio panel lists the project-code warnings, and each agent's Tools section shows what every tool changes, whether it asks for approval first and what registers it. An exported project declares its tools in agent_service/plugin.py and validates clean.

Studio issueSeverityWhat to do
COMPONENT_PENDING_BUILDWarningPlugins are declared and no build has described them yet; build the project to validate their names.
UNDECLARED_CODE_COMPONENTErrorThe project has code but nothing declared registers the name; declare a plugin in agent.yaml.
UNKNOWN_TOOL · UNKNOWN_PLANNER · UNKNOWN_VERIFIER · UNKNOWN_POLICY …ErrorNo source registers the name; the fix uses the closest name (with the permissions it needs) or removes an unknown tool. A guardrail is never removed by a fix.
MISSING_TOOL_PERMISSIONErrorThe fix grants every permission the agent's tools need.
PLUGIN_LOAD_FAILED · COMPONENT_CONFLICTErrorThe build could not load the plugins, or a plugin registers a name the Runtime or the manifest already has.
MISSING_ENVIRONMENTAt deployA variable an env:// reference or a plugin needs has no value; set it in Connections, which lists both.

Toolkits that cannot start (0.1.0a19, 0.1.0a20). A toolkit whose client fails to open at start degrades the service (/health/ready answers degraded and names it) instead of stopping it; build it with Toolkit(..., required=True) to keep failing fast. From 0.1.0a20 a client that fails on its configuration (a missing setting, credential or extra) raises ToolkitConfigurationError, both a ConfigurationError and a ToolNotAttemptedError, with the toolkit's own message. A plugin can also register a policy every agent must run: context.policies.register(policy, mandatory=True).

From 0.1.0a21 a project that runs its own server or workers, rather than deploying through Studio, checks with algen-agent-runtime check agent.yaml --path src --profile standalone. It keeps every error but leaves out the warnings about code Studio cannot run (OWN_CONTAINER, DIRECT_ENV_READ, constructor and post-build registration, code-defined agents). The default profile, studio, is unchanged; the report's profile field names the one used.

Build

Multi-agent workflows#

Runtime-owned DAGs compose agent nodes, deterministic handlers, dynamic fan-out, predicates, joins, bounded loops, repair, approvals, and exact-version child workflows. Domain behavior is named in YAML and implemented in a hook provider — never embedded as code in the manifest.

quickstart_approval — agent.yaml
Explorer
quickstart_approval
workflows:
  approval-quickstart:
    name: approval-quickstart
    version: 1.0.0
    hook_provider: examples.quickstart_approval.hooks:create_hooks1
    nodes:
      - id: propose2
        kind: agent
        agent: approval-proposer
        input_builder: approval.propose
        output_key: proposal
      - id: approve3
        kind: approval
        depends_on: [propose]
        output_key: approval
        approval:
          prompt: Review this synthetic change before it is applied.
          review_from: [proposal.change]
          parameters_from: proposal.parameters
          allow_modification: true
          rejection_policy: skip_dependents
          expires_seconds: 1800
      - id: decide4
        kind: handler
        depends_on: [approve]
        handler: approval.apply
        output_key: result
ProblemsOutputTerminal
$ python -m examples.quickstart_approval.app --approve-all
# --approve-all resolves the checkpoint; --reject-all and --answers-file PATH also work
# omit them in a TTY to approve or reject interactively
YAML3 nodes · DAGfingerprint pinned

An approval workflow from examples/quickstart_approval (excerpt)

  • 1Hook provider — trusted Python that supplies named builders, handlers, and schemas.
  • 2Agent node — invokes a registered agent; output stored under output_key.
  • 3Approval node — durable human checkpoint with optional parameter edits.
  • 4Handler node — deterministic application logic after the decision.

Node kinds#

KindPurposeRuntime guarantee
agentInvoke a registered agent definition.Typed input/output and child run lineage.
map_agentFan out over bounded input items (map_from).Deterministic collection and concurrency limits.
handlerRun trusted application logic.Registered action names only.
predicateBranch on an allowlisted condition.Operators such as equals, contains, truthy, in.
joinCombine upstream evidence.concat, json_array, or first over explicit dependencies.
approvalPause for a human decision.Persisted pause, attributed decision, expiry.
workflowCompose an exact trusted child version.Parent/root lineage and duplicate-safe reconciliation.

Service workflow lifecycle#

From 0.1.0a16 the service looks after the workflow runs it starts. A run started through the HTTP workflow API (POST /v1/workflows/{name}/runs) records owner: "workflow_service" in its checkpoint (the new optional WorkflowExecutionState.owner). Runs an application starts with its own executor have no owner, and the service never touches them.

  • Recovery on start. With runtime.recover_incomplete_runs and runtime.recover_service_workflows (both default true), startup recovers each owned top-level run that was pending or running through executor.recover, with the exact configured manifest (same name, version and content fingerprint), hooks loaded only from security.trusted_plugin_prefixes, and each node's recovery_policy (an interrupted node retries only with retry; otherwise the run fails closed). A run whose manifest changed or is no longer configured, or whose hooks are not trusted, is left untouched and logged. Child workflows are recovered through their parent; waiting runs are not resumed. If recovery fails, the service still starts and readiness reports workflow.recovery as degraded.
  • Expiry of waits. The maintenance sweep (maintenance.expire_paused_workflows, default true) expires an owned run's approval wait past its expires_at (workflow.approval.expired) and, with maintenance.clarification_timeout_seconds set, an input wait older than that (workflow.input.expired, the node fails with workflow input expired). It goes through the engine's own expiry path, so rejection policies, dependents, checkpoints and the webhook behave as for a late decision, and repeated or concurrent sweeps expire a wait once. It runs only in the serving process (maintenance.enabled) and reports expired_workflow_pauses; the maintenance command does not drive workflows.
  • Drain. On shutdown the workflow service is drained before the runtime: running workflows get shutdown_grace_seconds to finish or pause, and unfinished runs keep their last checkpoint, recoverable, instead of being marked cancelled. The agent run a workflow step waits on is drained by the runtime, not cancelled. Starting or answering a workflow run during shutdown returns 409.

An application that drives its own executor does the same itself: executor.recover(...) on start, and executor.expire_pause(manifest, workflow_id, tenant_id=..., input_timeout_seconds=...) on its own schedule. It ends an expired approval or input wait, including one inside a child workflow, which the parent then consumes, and returns a run that is not waiting, or not yet expired, unchanged. MultiAgentWorkflowExecutor also gains pause_expired, manifest_matches, the interrupted constructor argument and run(..., owner=...); WorkflowService has recover, expire_paused, drain and idle, and Container.attach_workflows attaches one.

Upgrade note: runs without an owner. Workflow runs started through the HTTP API before 0.1.0a16 have no owner, so they are neither recovered on start nor expired by the sweep. Decide or answer them through the API, or recover them once with executor.recover. See Multi-agent workflows and Operations · workflow recovery.

Workflows are fingerprinted. A paused workflow resumes only against the same manifest name and version that started it, so a redeploy cannot silently change an in-flight run. See Multi-agent workflows for recovery semantics.

Changed in 0.1.0a19. An agent node's unparseable JSON output goes through the repair loop up to max_repairs (more model calls) and, once repairs run out, fails with VerificationError instead of ConfigurationError. Workflow tool: steps work under algen-agent-runtime serve. Agent nodes can pass the run's files with pass_attachments; see File attachments.

The Runtime's workflow guide (0.1.0a21) documents the hook factory's keyword arguments under serve (container, manifest, environment), every WorkflowHookContext field and which hooks receive it, each hook kind's signature, and how serve loads hooks and recovers workflows: hook provider reference. Its MCP guide documents the tool-result shape, and its operations guide has a route-by-route scope table.

Runtime a16 · conversation turns

Conversation turns#

A conversation turn is one user message and the assistant message that answers it. From 0.1.0a16 the built-in runtime conversation handler, which runs one agent run per turn, streams the answer as it is written, pauses the turn when its run needs an approval or an answer, and resumes into the same assistant message, also after a restart.

New in 0.1.0a16. Install it with python -m pip install "algen-agent-runtime==0.1.0a16". Studio's Runtime floor is still 0.1.0a13, so this behaviour applies only where 0.1.0a16 or later is installed.

Streaming#

When conversation_streaming.enabled is true (the default) and every model the agent may use can stream, the turn's run is started with stream=True and its text reaches GET /v1/conversations/{id}/events as assistant.message.delta events, with data {message_id, delta}. A turn whose models cannot stream gives only the final message, as before.

agent.yaml · conversation streaming (defaults)
conversation_streaming:
  enabled: true           # false: only the final message, and turns use the model-response cache again
  delta_interval_ms: 50   # at most one delta event per interval per turn; smaller deltas are coalesced
  delta_max_chars: 2048   # largest delta event; a fuller buffer is sent at once, in pieces of this size

Deltas are provisional and may include text the model wrote before a tool call: the assistant.message.completed message is the record. They carry only model text, never tool arguments or results, and are not audited. Streamed model calls bypass the model-response cache, as streaming runs always have.

Approvals and questions in a turn#

When the turn's run awaits an approval or a clarification, the assistant message moves to awaiting_approval or awaiting_input. Its pending action, also in the assistant.message.awaiting_approval or assistant.message.awaiting_input event, carries what a UI needs to ask: turn_id, run_id, approval_id, tool, proposed_action, side_effect_summary, risk, expires_at, options, redacted_parameters (as in the run's approval.required event; never raw arguments) and the tool's parameters_schema, or the question to answer.

RouteBodyScope
POST /v1/conversations/{id}/turns/{turn_id}/approval{"decision": "approved" | "rejected" | "modified", "modified_parameters": {...}, "reason": "..."}conversations:write
POST /v1/conversations/{id}/turns/{turn_id}/input{"text": "..."}conversations:write

turn_id is the user message id (pending.turn_id), and the authenticated user is recorded as decided_by. Both routes return 202 with the assistant message id and streaming; the answer resumes the run, which keeps streaming into the same assistant message (assistant.message.resumed). Another tenant or user gets 404. An answer of the wrong kind, a second answer, or an answer to a turn that no longer waits gets 409; an answer after the approval expired ends the turn expired and gets 409. A rejected action ends the turn failed with the Runtime's “Proposed action was rejected.” text (metadata.reason: approval_rejected).

host · answer a waiting turn in Python
from algen_agent_runtime.conversations import ConversationApprovalDecision, ConversationInputAnswer

await container.conversations.respond(
    conversation_id, turn_id, tenant_id, user_id,
    ConversationApprovalDecision(decision="approved"),
    credentials={"authorization": request_headers["authorization"]},
)
# A question is answered with ConversationInputAnswer(text="The Berlin office").

ConversationService.respond(conversation_id, turn_id, tenant_id, user_id, response, *, credentials=None) takes a ConversationApprovalDecision (approved, rejected, or modified with modified_parameters) or a ConversationInputAnswer. ConversationPendingAction, ConversationTurnResponse, PendingTurnExpiredError and ResumableConversationHandler are exported from algen_agent_runtime.conversations too, and ConversationTurnResult takes the outcomes awaiting_approval, awaiting_input, cancelled and expired with pending.

One waiting turn per conversation#

Only one turn per conversation can wait. A new message while one waits is refused with 409 naming the waiting turn id: answer it, or cancel it with DELETE /v1/conversations/{id}/active-response, which also cancels the paused run. Deleting the conversation cancels it too.

Restarts#

The pending action is stored on the assistant message and the paused run in the run store, so a restart loses neither and nothing re-runs. recover() leaves a waiting turn waiting, and the user can answer it after the restart. If the run was resumed but the process stopped before the message was updated, recovery follows the run to its end instead of running it again; if the run ended while the turn waited, the turn ends with that outcome. ConversationService.reconcile_pending_turns() does this, and the maintenance sweep runs it for turns whose paused run it expires (reconciled_pending_turns). A custom handler opts in by implementing ResumableConversationHandler (resume, follow, settle, pending_state, cancel_pending).

Credentials of the caller#

ConversationService.submit(..., credentials=...) and respond(..., credentials=...) pass the caller's credentials to the turn's run for its tools, exactly as Runtime.start and resume do (see Credentials of the caller). They are held in memory only, never stored, emitted, audited or logged; a turn that pauses drops them, so pass them again when answering. The HTTP routes never forward request headers as credentials. A custom handler receives them by accepting a credentials keyword argument in handle; passing credentials to a handler that does not is a ConfigurationError.

Message statuses and events#

Message statusMeaningEvent
accepted · streamingQueued, running (also after an answer).assistant.message.started · assistant.message.delta · assistant.message.resumed
awaiting_approvalThe run waits for an approval decision.assistant.message.awaiting_approval
awaiting_inputThe run asked the user a question.assistant.message.awaiting_input
completedAnswered.assistant.message.completed
failedFailed, or the action was rejected (metadata.reason: approval_rejected).assistant.message.failed
cancelledCancelled by the user, or with its run.assistant.message.cancelled
expiredThe approval or answer did not come in time.assistant.message.expired

assistant.message.resumed data is {turn_id, kind, decided_by} plus approval_id and decision for an approval, or the answer text for input. The awaiting, completed, failed and expired events carry the assistant message, with turn_id on the awaiting events. A resumed turn opens a new conversation.turn span with conversation.turn.resumed=true; its agent run continues the run's original trace.

Behaviour changes and limits. Turns of the runtime handler now stream by default, so they emit assistant.message.delta events and their model calls skip the model-response cache; set conversation_streaming.enabled: false to keep the previous behaviour. A turn whose run pauses now waits instead of completing with an empty message, and a run that is cancelled ends its turn cancelled. Pending turns are reconciled and their events fanned out per process, like the rest of the conversation layer: run one Runtime process per deployment. See Operations · conversation turns and the turn sequence.

Runtime a19 · file attachments

File attachments#

From 0.1.0a19 an agent can read files a caller gives it: a photo of an ID document, a scanned invoice, a screenshot or a PDF. The model sees the file natively (vision or PDF input) in the same call as the question. Files are passed by reference to an uploaded artifact, never as inline bytes. Attachments are off by default: an agent or workflow opts in, and text-only runs are unchanged.

New in 0.1.0a19. Install it with python -m pip install "algen-agent-runtime==0.1.0a21". Send attachments only once every API process and worker runs 0.1.0a19 or later; earlier replicas can read what it stores, but cannot load the files.

Opt in#

agent.yaml · attachments
agents:
  - name: kyc-reader
    attachments:
      enabled: true
      types: [image/png, image/jpeg, image/webp, image/gif, application/pdf]  # the default
      max_count: 5          # default 5, at most 32
      retention: turn       # turn (default) or session
security:                   # the deployment's outer limits; an agent can only narrow them
  allowed_attachment_types: [image/png, image/jpeg, image/webp, image/gif, application/pdf]
  max_attachments_per_run: 10
  max_attachment_bytes_total: 20971520   # 20 MiB per run
  upload_scanner: basic                  # none (default) or basic

GET /v1/agents and GET /v1/workflows return each one's attachments policy. Show an attach control only when it has enabled: true and its default or fallback model reports the capability (images, or files for PDF), and use types as the file picker's accept list.

Upload, then run#

POST /v1/runs with a file
curl -X POST -H 'X-Tenant-ID: acme' -H 'X-User-ID: clerk' -H 'X-Scopes: runs:write' \
  -H 'Content-Type: application/json' http://localhost:8000/v1/runs -d '{
    "agent": "kyc-reader",
    "input": "What kind of document is this, and is it expired?",
    "attachments": [{"artifact_id": "9b2c…", "media_type": "image/jpeg"}]
  }'

Upload first with POST /v1/artifacts. The same attachments list is accepted by RunRequest.attachments, POST /v1/conversations/{id}/messages, POST /v1/workflows/{name}/runs, algen-agent-runtime run AGENT "question" --attach passport.jpg and evaluation cases (GoldenQuestion.attachments). input stays required; media_type is optional, and when given must match the file's real type.

Checked before any model call#

  • The agent (or workflow) opted in, and every artifact exists in the caller's tenant and is available.
  • The real type, read from the bytes, is allowed by the agent and by security.allowed_attachment_types. Formats models do not read, such as HEIC, are refused; nothing is converted.
  • Count and total size are within max_count, max_attachments_per_run and max_attachment_bytes_total; images are at most 5 MB and 8000 px a side, PDFs at most 32 MB.
  • A configured model can read them. Models without the capability are skipped, fallbacks included; a file is never silently dropped.

Hosts can veto or filter files with a policy on the attachments point. It receives {artifact_id, media_type, name, size_bytes, sha256} per file, never content.

References in history, bytes at dispatch#

History, memory, checkpoints, conversation records, events, audits and spans hold only file blocks (artifact_id, media_type), never base64, even with telemetry content capture on. The bytes are loaded onto a copy of the request just before each provider call. A file deleted or expired since becomes a note instead of failing the run. With retention: turn the file is replaced by a note in session memory after the turn, so later turns do not pay for it again; context budgets count about 1,600 tokens per image and 8,000 per PDF.

ProviderImagesPDF
Anthropicimage blocks (base64, or URL)document blocks
OpenAIimage_url data URLsfile parts
Other OpenAI-compatibledata URLs where images: truerefused
Mock, local transformersrendered as [attachment: name, type]

Tools and workflows#

ToolContext.attachments lists the run's files and await context.read_attachment(artifact_id) returns one with its bytes, from the run's tenant only. In algen-agent-tools, documents_toolkit(artifact_loader=runtime_attachment_loader) extracts the text of an attached document through the document security checks. A workflow declares attachments: {enabled: true}, and each agent node that should see the files sets pass_attachments: true; settings validation fails when a node passes files to an agent that does not accept them.

Scanning and retention#

Uploads start pending_scan and cannot be attached until something makes them available. security.upload_scanner: basic runs BasicArtifactScanner on each upload: it quarantines an image or PDF whose bytes are not what it claims, is malformed or exceeds model limits, and records scan evidence before the status changes. It does not inspect content; add your own ArtifactScanner for that. ID documents and similar uploads are sensitive personal data: give them a short expires_in_seconds and schedule ArtifactStore.purge_expired().

Build · new in 0.1.0a4

Agent Manifest UI#

A Swagger-style visualizer for agent.yaml. It reads validated settings and renders the agent catalog, workflow DAG, provider and model matrix, tools and MCP servers, retrieval stores, a read-only execution view, and the raw manifest. Output is self-contained HTML with no CDN dependency.

127.0.0.1:8000/agent-uiRead-only
Agent Manifestsupport-desk
v1.2.0 2valid · 0 warnings Runtime 0.1.0a8
4

refund-review v1.0.0

Click a node to inspect its contract. Edges show depends_on.

fingerprint 9b1e…c41d
agent handler join approval 7 nodes · 7 edges
5agent.yaml validated3 agents1 workflow2 providers4 toolsGET /agent-ui/spec

Interactive sample for a support-desk project — switch views, click workflow nodes, and edit the execution form. Illustrative Algen-themed rendering; the shipped UI's styling may differ.

  • 1Views — overview, workflow DAG, agents, providers, tools and MCP, knowledge, execution, and source.
  • 2Validation — the page is built from validated settings, so references and secrets are already checked.
  • 3Export — save the whole manifest as one self-contained HTML file.
  • 4Workspace — the workflow canvas with a node inspector for each contract.
  • 5Spec endpoint — the same data as JSON at /agent-ui/spec.
zsh
$ algen-agent-runtime ui --config agent.yaml --output agent-ui.html --open
# export-ui is an alias of ui; use '-' to write HTML to stdout
$ algen-agent-runtime export-ui -c config/agent.yaml -o -

Keep the served UI internal. The route is disabled by default and returns 404 with guidance when disabled. It renders configuration detail, so expose it only on trusted networks and behind your own access controls.

Build · from your IDE

Skills & prompts#

Runtime ships Agent Skills: SKILL.md instruction packs that teach coding agents in your IDE how to build on Runtime the right way — offline first, governed tools, env:// secrets, validated manifests, and Studio-importable projects. Copy the skills into your project, then start from a prompt below.

Copy skills→ Paste a prompt→ Agent builds & edits→ Validate offline→ Import into Studio

Available skills#

Add skills to your project#

  1. Copy the skill foldersEach skill is a folder with a SKILL.md and a references/ directory it reads on demand — copy the whole folder, not just the file.
  2. Place them where your agent looksYour coding agent loads a skill when its description matches the task. You can also name the skill in your prompt.
  3. PromptCopy a prompt from the library below, replace the [placeholders], and send it in agent mode.
zsh — in your project root
$ git clone --depth 1 https://github.com/AlgenAI/algen-agent-runtime.git /tmp/algen-runtime
$ mkdir -p .agents/skills
$ cp -R /tmp/algen-runtime/.agents/skills/algen-agent-builder /tmp/algen-runtime/.agents/skills/algen-agent-connector .agents/skills/
# extending Runtime itself? also copy algen-runtime-extension and algen-runtime-contributor
Coding agentProject skills folder
GitHub Copilot in VS Code.github/skills/ or .agents/skills/
OpenAI Codex.agents/skills/
Claude Code.claude/skills/

Skill locations are still evolving across tools. If a skill is not picked up, check your agent's documentation for its skills folder, or paste the SKILL.md below directly into your agent's custom instructions.

my-agent — .agents/skills
Explorer
.agents/skills algen-agent-builder algen-agent-connector algen-runtime-extension algen-runtime-contributor
---
name: algen-agent-builder
description: Create or update an Algen-compatible single-agent or multi-agent project that uses Runtime manifests, governance, tests, and Studio-importable packaging.
---

# Build an Algen agent project

Start by choosing the smallest topology that fits: one agent for a single governed conversation, or
a Runtime `WorkflowManifest` for coordination. Scaffolding is available via the CLI:
`algen-agent-runtime new <name> [--template agent|approval|workflow]`. Read `references/project-shape.md`;
for a workflow, also read `references/workflows.md`. Use `$algen-agent-connector` when external tools,
safe starter tools (`algen_agent_runtime.tools.starter`), databases, retrieval, MCP, or remote APIs are part
of the request.

Prefer configuration for provider routing, policies, memory, retrieval, storage, telemetry, and
workflow topology. Keep only domain-specific payload builders, validators, schemas, predicates, and
deterministic handlers in the project's hook provider. Never implement a second scheduler or a
Studio-specific workflow dialect. For automated CI and headless evaluation, use non-interactive workflow
options (`--approve-all`, `--reject-all`, `--answers-file PATH`).

Produce an importable project with secret references, bounded execution, deterministic offline
tests, documented optional dependencies, and a small runnable entry point. A project intended for
Studio must keep its Runtime configuration and `WorkflowManifest` authoritative. Studio sees only
what the settings declare, never constructor arguments, so:

- Every name an agent uses (`enabled_tools`, `planning_strategy`, `response_composer`, `verification_policy.verifiers`,
  `context_builder`, `guardrail_policy.policies`, provider types) must come from the Runtime,
  the settings (`http_tools`,
  `mcp_servers`, `providers`, `retrieval`) or a plugin listed in `domain_plugins`. Register code
  components from a declared plugin, never through `build_container(additional_tools=...)`; see
  `docs/plugins.md`.
- Credentials only through `env://` references; list a plugin's variables in `PluginManifest.env`.
- If the project runs its own server, workers or approval flow, say plainly that Studio runs only
  the Runtime contract, not that code.
- `build_container` fails on any name no source registered and suggests the closest one; fix the
  name or its registration, never switch `runtime.component_check` to `warn` to hide it.
- A project that runs its own host (not Studio) checks with
  `algen-agent-runtime check agent.yaml --path src --profile standalone`, which leaves out the
  Studio-only warnings but keeps every error.
- Before calling a project Studio-importable, run `algen-agent-runtime check agent.yaml --path src`.
  It must report no errors; read every warning to the user, since each names code Studio will not
  run or cannot see. `algen-agent-runtime describe --path src` shows the full inventory, and
  `docs/studio-compatibility.md` lists what Studio can and cannot see.
- Write tools as typed functions with `@tool` or a `Toolkit` and a preset (`docs/tool-authoring.md`),
  and test each with `assert_tool_contract`.

An agent that makes many tool calls or changes state should use the Runtime's own guards rather than
rules in its instructions or plugin code: prechecks, refusal limits, the repeat guard and call
limits, the wrap-up notice and shortened older tool results. A tool that must act as the signed-in
user reads the caller's credentials from `ToolContext.credentials`. See "Tool-heavy and acting
agents" in `references/project-shape.md`.

Validate YAML by loading it through the current Runtime model rather than relying on syntax alone.
Run the project smoke test and relevant Runtime tests. Do not publish to a marketplace or deploy
unless the user explicitly requests that external action.
MarkdownAgent Skills · SKILL.mdreferences/ loaded on demand

The skills bundled with Runtime — Copy takes the active SKILL.md

Prompt library#

Each prompt names the skill it relies on and keeps the first run offline on the mock provider. Replace the highlighted [placeholders] before sending.

Governed chat agent

One agent, offline first, ready for Studio.

Use the algen-agent-builder skill to create an Algen Agent Runtime project named [support-assistant] that [answers customer questions about our return policy].

- Scaffold it with algen-agent-runtime new [support-assistant] --template agent.
- Use the mock provider with the deterministic model so the first run needs no API keys. Add [OpenAI] as a second provider instance with its key as an env:// reference.
- Keep the default guardrails, set max_steps to [8], and set a budget of [20000] tokens and [$0.50] per run.
- Add an offline pytest smoke test and a short README.

Validate agent.yaml by loading it with load_settings, then run the tests.
Agent modealgen-agent-builder

Agent with a typed tool

A governed, read-only API lookup.

Use the algen-agent-builder and algen-agent-connector skills to give the [support-assistant] agent a tool that [looks up an order by ID from https://api.example.com/orders].

- Define it as a ToolDefinition with strict input and output JSON Schemas, side_effect READ, a timeout, and a result-size limit.
- Read the base URL and token from env:// references; never hard-code secrets.
- Allow outbound traffic only to [api.example.com].
- Enable the tool on the agent and test it with a deterministic fake, not the live API.
Agent modeagent-builderagent-connector

Human approval workflow

Propose, review, then apply idempotently.

Use the algen-agent-builder skill to create an approval workflow for [issuing refunds above $50].

- Scaffold it with --template approval.
- Nodes: an agent node that proposes the [refund], an approval node with allow_modification and a parameters_schema for [amount and reason], and a handler node that applies the change idempotently.
- Use rejection_policy skip_dependents and expires_seconds [3600].
- Support --approve-all, --reject-all, and --answers-file for headless runs, and add tests for approve, modify, and reject.
Agent modealgen-agent-builder

Multi-agent research fan-out

Planner, parallel researchers, join, writer.

Use the algen-agent-builder skill to build a multi-agent workflow that [researches a company from several angles and writes a cited brief].

- A planner agent returns a bounded list of [3–5] sub-questions.
- A map_agent node runs one researcher agent per item with a concurrency limit.
- A join node combines the findings, then a writer agent produces the brief.
- Validate every node output with JSON Schema and keep domain logic in the hook provider.
- Run the whole workflow offline on the mock provider in tests.
Agent modealgen-agent-builder

Retrieval with citations

Grounded answers over your own documents.

Use the algen-agent-connector skill to add retrieval to [support-assistant] over [the Markdown files in ./knowledge].

- Index with tenant filters, treat retrieved text as untrusted, and require verified citations in answers.
- Use the in-memory vector store in tests and [pgvector] in production behind its optional extra.
- Add test cases where the answer must cite a source and one where it must decline.
Agent modealgen-agent-connector

MCP-connected agent

Remote tools under operator policy.

Use the algen-agent-connector skill to connect [support-assistant] to the [inventory] MCP server at [https://mcp.example.com/mcp] over streamable_http.

- Declare it under mcp_servers with allowed_tools limited to [lookup_item and update_stock].
- Put the token in auth_token_ref as an env:// reference.
- Add tool_policies so [update_stock] is side_effect write and non_idempotent, which makes it require approval.
- Mark the server optional and test against a fake MCP server.
Agent modealgen-agent-connector

Govern an existing framework agent

Wrap LangGraph or OpenAI Agents SDK code.

Use the algen-agent-connector skill to run our existing [LangGraph] agent in [app/graph.py] under Algen Agent Runtime.

- Register it explicitly through build_container(framework_adapters=...); do not import it from YAML.
- Propagate tenant, user, and session identity, and normalize streaming and cancellation.
- Document which tools and guardrails stay framework-owned and which Runtime governs.
- Add a credential-free test using a scripted or fake model.
Agent modealgen-agent-connector

Offline evaluation suite

Golden cases that gate every change.

Use the algen-agent-builder skill to add an offline evaluation suite to [support-assistant].

- Write [10] golden cases covering the most common requests and answers the agent must never give.
- Check expected phrases, forbidden content, tool calls, and citations.
- Run on the deterministic mock provider in CI and fail the build below a [100%] pass rate.
Agent modealgen-agent-builder

Production readiness review

Find and close deployment gaps.

Use the algen-agent-builder and algen-agent-connector skills to review [this project] against the Algen Agent Runtime production checklist.

- Durable PostgreSQL and Redis stores with env:// DSNs.
- JWT authentication instead of development headers, with rate limits on.
- Outbound allowlists, timeouts, and telemetry with content capture off.
- Tests for approval resume and interrupted non-idempotent tools.

List the gaps first, then fix the ones inside this project.
Agent modeagent-builderagent-connector

New model provider

An optional adapter behind Runtime contracts.

Use the algen-runtime-extension skill to add a model provider adapter for [Provider X] to Algen Agent Runtime.

- Implement it against the Runtime provider contract and normalize requests, responses, streaming, usage, errors, and cancellation.
- Keep the SDK an optional extra that fails with an actionable install message.
- Add credential-free contract tests, put live tests behind the live marker, and update the docs and CHANGELOG.
Agent modealgen-runtime-extension

Contribute a Runtime change

Tests, docs, and compatibility included.

Use the algen-runtime-contributor skill to implement [the change] in Algen Agent Runtime.

- Follow CONTRIBUTING.md and keep public contracts provider-neutral.
- Add the narrowest meaningful test, including the failure path.
- Update docs, examples, and CHANGELOG for user-visible behavior, and run the quality gates.

Do not commit or push until I ask.
Agent modealgen-runtime-contributor

Review before you run. Coding agents can make mistakes. Keep the first run on the mock provider, read the generated agent.yaml and tool classifications, and only then add real credentials as env:// references.


Govern

Policies & budgets#

Governance is evaluated at explicit boundaries, not inferred from prompts. Input and output policies, model permissions, budgets, tool classifications, and human decisions remain separately auditable, with stable denial codes.

Agent fieldControlsDefaults
guardrail_policyNamed policies evaluated at input, plan, tool, retrieval, memory, and response points; optional require_citations.secrets, pii, prompt_injection, output_validation, authorization
budgetHard ceilings per run.max_tokens 100000 · max_output_tokens 8192 · max_cost_usd 10.0 · max_latency_seconds 300
budgetHard ceilings per run; see Run limits.max_tokens 100000 · max_output_tokens 8192 · max_cost_usd 10.0 · max_latency_seconds 300 · max_model_calls 1000 · max_response_chars 1000000 · max_events 100000
verification_policyOutput verifiers, minimum confidence, and bounded repair attempts.non_empty · confidence 0.0 · max_repairs 1
tool_policyWhether a failed tool call fails the run or returns to the model, and whether one reply's calls run in parallel. See tool policy.fail_run · sequential
tool_policy guards · wrap_up_policyRefusal limits, repeat guard, per-tool call limits and the wrap-up notice. See Tool-call guards.off · schema check always on
approval_policyWhen tool calls pause for a human.side effects and destructive calls require approval · expires in 3600 s
memory_policyRetention, item limits, PII redaction, tool-payload storage.enabled · 86400 s · 200 items · PII redacted
model_allowlist · tool_permissionsWhich models and tool permissions an agent may use.empty

Policy gates

Check tenant, user, permission, model, source, purpose, budget, and output rules with stable denial codes.

Durable approvals

Persist pause state before returning control. Resume with attributed approve, reject, or modify decisions.

Usage metering#

A deployment that bills or rations model use passes a UsageMeter to build_container(settings, usage_meter=...) (or ModelRouter(usage_meter=...)). It is a deployment setting, with no agent.yaml field. The router reserves before every provider dispatch and settles afterwards, so agent steps, retries, fallbacks, schema repairs, streamed calls and history compaction are all charged; a failed, errored or cancelled call is released, and a cached response is not charged. Without a meter nothing changes.

app.py · usage meter
from algen_agent_runtime import MeterRefusedError

class CreditMeter:  # satisfies UsageMeter
    async def reserve(self, *, max_input_tokens: int, max_output_tokens: int) -> object:
        hold = await wallet.hold(max_input_tokens + max_output_tokens)
        if hold is None:
            raise MeterRefusedError("You have used this month's model allowance.")
        return hold

    async def settle(self, ticket: object, usage: TokenUsage) -> None:
        await wallet.charge(ticket, usage.input_tokens + usage.output_tokens)

    async def release(self, ticket: object) -> None:
        await wallet.cancel(ticket)

container = build_container(settings, usage_meter=CreditMeter())

max_input_tokens is the router's estimate_request_tokens for the chosen model (about four characters a token, tools and schema included) and max_output_tokens the request's output cap; settle receives the provider's reported usage. To refuse, raise MeterRefusedError: the call is not retried, not passed to a fallback model and not counted against provider health. The run ends failed with the reason on RunState.error, a budget.refused event and audit record, run.failed with reason: budget_refused, and budget.refused on the agent.run span. Any other exception from reserve is an internal error, not a refusal. The meter holds its own state, so a run resumed after approval is charged only for the calls it makes after resuming. It replaces the Algen Agent Harness's CreditMeter and MeteredProvider: there is no provider to wrap, and the reason is read from the run's error.

app.py · usage meter
from dataclasses import dataclass

from algen_agent_runtime import MeterCall, MeterRefusedError
from algen_agent_runtime.orchestration.container import build_container
from algen_agent_runtime.types.contracts import TokenUsage

# Credits per token, by model; anything else costs 1.
MODEL_WEIGHTS = {"gpt-4.1": 4.0, "gpt-4.1-mini": 1.0}


@dataclass(frozen=True)
class Hold:
    account: str
    call: MeterCall  # kept for settle, which receives only the ticket
    hold_id: str


class CreditMeter:  # satisfies UsageMeter
    def __init__(self, wallets) -> None:
        self._wallets = wallets

    async def reserve(
        self, *, max_input_tokens: int, max_output_tokens: int, call: MeterCall
    ) -> object:
        # Charge the workspace when the host passed one in RunRequest.metadata, else the tenant.
        account = call.metadata.get("account_id") or call.tenant_id or "unattributed"
        weight = MODEL_WEIGHTS.get(call.model, 1.0)
        hold_id = await self._wallets.hold(account, (max_input_tokens + max_output_tokens) * weight)
        if hold_id is None:
            raise MeterRefusedError("You have used this month's model allowance.")
        return Hold(account=account, call=call, hold_id=hold_id)

    async def settle(self, ticket: object, usage: TokenUsage) -> None:
        assert isinstance(ticket, Hold)
        weight = MODEL_WEIGHTS.get(ticket.call.model, 1.0)
        tokens = (usage.input_tokens + usage.output_tokens) * weight
        await self._wallets.charge(ticket.account, ticket.hold_id, tokens)

    async def release(self, ticket: object) -> None:
        assert isinstance(ticket, Hold)
        await self._wallets.cancel(ticket.account, ticket.hold_id)


container = build_container(settings, usage_meter=CreditMeter(wallets))

max_input_tokens is the router's estimate_request_tokens for the chosen model (about four characters a token, tools and schema included) and max_output_tokens the request's output cap; settle receives the provider's reported usage, with estimated_cost_usd filled in when the provider has configured rates.

Whose call it is: MeterCall#

From 0.1.0a15 reserve receives call, a frozen MeterCall (exported from algen_agent_runtime) that says whose call it is and which model is being tried. One router serves every tenant and run, so a multi-tenant meter charges from it rather than from host context variables, which do not survive a pause or a restart. settle and release get only the ticket, so keep the MeterCall in the ticket when you need it there.

FieldValue
tenant_id, user_idFrom the run request.
run_id, session_idThe run and its session.
parent_run_id, conversation_id, turn_idFrom the run request, when set.
agent, agent_versionThe agent's name and the version that runs.
step_idThe step that makes the call; None for compaction.
purpose"step" for agent steps (retries and schema repairs included), "compaction" for history compaction, "other" for a call made on ModelRouter directly.
attemptThe retry attempt, from 0. A fallback to another model within one attempt keeps the attempt.
providerThe provider registration id (the providers: key) of the candidate being tried.
modelThe model of the candidate being tried; a fallback reports its own.
streamingWhether the call is streamed.
metadataA read-only copy of RunRequest.metadata, for an account id such as a workspace. The Runtime never puts these values in events, audits or spans.

The run fields come from the persisted run, so a run resumed after an approval pause, or recovered by Runtime.recover() after a restart, reports the same tenant, user and run as before. A cached model response makes no call, so the meter never sees it. A call made on ModelRouter.generate or ModelRouter.stream directly gets a MeterCall with only provider, model, streaming and purpose="other"; pass meter_call= to supply the rest.

The 0.1.0a14 form is deprecated. A meter whose reserve has no call parameter (and no **kwargs) still works: the router inspects reserve once, when it is built, and then calls it without call, emitting one DeprecationWarning per meter, attributed to the meter's module. That form is removed in 0.2: add call: MeterCall to reserve; nothing else changes.

To refuse, raise MeterRefusedError: the call is not retried, not passed to a fallback model and not counted against provider health. The run ends failed with the reason on RunState.error; a budget.refused event and audit record carrying reason, provider (the registration id) and model of the refused candidate, never metadata values; run.failed with reason: budget_refused; and budget.refused on the agent.run span. The router also sets provider and model on the MeterRefusedError before re-raising it. Any other exception from reserve is an internal error, not a refusal. The meter holds its own state, so a run resumed after approval is charged only for the calls it makes after resuming. See Metering model calls and Operations · usage metering.

Untrusted document inspection#

extractor_for(filename) in algen_agent_runtime.security.documents turns an uploaded document into text a model can safely read: the full text, a sanitized_text with only what a reader would see, security findings and a disposition (pass, review or quarantine). 0.1.0a14 adds Excel, PowerPoint, HTML and CSV/TSV, all on the standard library.

FormatExtractorExtra
PDFPdfDocumentExtractordocuments (PyMuPDF)
DOCXDocxDocumentExtractornone
XLSXXlsxDocumentExtractornone
PPTXPptxDocumentExtractornone
HTML (.html, .htm)HtmlDocumentExtractornone
TXT, Markdown, CSV, TSVPlainTextDocumentExtractornone

Hidden sheets, rows, columns, slides, shapes and HTML elements are kept out of the sanitized text and reported as hidden_text.* findings. XLSX and PPTX get the DOCX package limits (part count, size, compression ratio, no DTD) and the macro and remote-content findings. XLSX text is one line per row under --- Sheet: <name> ---, a formula contributes its last calculated value, date and time cells come out as ISO dates and times, and a sheet longer than max_rows_per_sheet (5,000) is cut and reported as document.truncated. PPTX text is one line per paragraph under --- Slide <n> ---, without speaker notes or comments; HTML text is the visible body only. Any other suffix raises ValueError naming every supported format; legacy .doc, .xls and .ppt are not supported.

Known limitations. Not detected yet: in XLSX, the ;;; number format and a font colour that matches the cell fill; in HTML, styles from stylesheets, font-size: 0 !important, opacity: .0, color: transparent and off-screen positioning; in PPTX (and DOCX), images linked from outside the file. See Untrusted document inspection.

From 0.1.0a18 a deployment can add rules every agent runs, as signed policy bundles, and decide what each boundary does when a policy fails; see Policy bundles & fail modes.

Start strict and loosen with evidence: keep default guardrails, set a small budget, and watch policy.evaluated events before raising limits.

Behaviour changes in 0.1.0a19. tool_result_injection screens the decoded strings of a tool result and catches instructions at the start of a line, so flag and block act on results they let through before. The pii guardrail leaves single addresses in tool result fields declared format: email (and mail tools' sender and recipient fields) unredacted, so an agent can reply to what it read. budget.max_cost_usd: 0 lets zero-cost models run; any spend still stops the run.

Output repair in 0.1.0a21. When an answer fails verification and max_repairs allows another try, the Runtime asks again with a user message named runtime_repair. It lists what failed as paths and constraints, never values (criteria[0].evidence: 4 items, at most 3). Claude 5.x models refused the old system message as an assistant prefill (HTTP 400). Schema limits a provider's structured output drops (maxItems, minimum, pattern, …) are written into the property's description, so the first answer usually meets them. Hosts that display history can filter the repair turn by its name; session memory does not keep it.

Runtime a18 · policy distribution

Policy bundles & fail modes#

The policy engine runs at eleven boundaries. From 0.1.0a18 each boundary has an explicit fail mode for a policy that raises or times out, a deny always beats an approval requirement, and an operator can distribute rules every agent runs as an Ed25519-signed bundle verified at start-up.

New in 0.1.0a18. Install it with python -m pip install "algen-agent-runtime[encryption]==0.1.0a18"; verifying signatures needs the encryption extra, unsigned bundles do not. Studio's Runtime floor is 0.1.0a17, so this applies only where 0.1.0a18 or later is installed.

Boundaries and fail modes#

When a policy raises, returns something other than a PolicyDecision, or runs past policies.evaluation_timeout_seconds, the boundary's fail mode decides. closed refuses the value as a deny would (reason_code: policy.error, a PolicyDeniedError); open skips only the failed policy and lets the others decide. A policy that raises PolicyDeniedError has decided: that is a denial in either mode.

BoundaryEvaluated onA refusal
inputThe run's input; each steering message before delivery.The run fails.
before_retrieval · after_retrievalThe request text before context is built; loaded history and the built context.The run fails.
before_model · after_modelEvery model request, compaction included; every reply's text and compaction summary.The run fails.
plan_toolEach requested tool call, before guards and approval.The call is refused (tool_policy.on_error).
before_toolThe call's arguments (agent runs and workflow tool steps); a redaction here also refuses the call.The call is refused.
after_toolThe output, before it is recorded, cached or shown to the model.The call fails.
final_responseThe verified answer.The run fails.
before_memory_write · after_memory_writeMemory, learned-memory proposals, working state, skills, archives; the count just written.The run (or the memory tool call) fails.
agent.yaml · policies (fail modes)
policies:
  on_error: closed                 # closed (default) | open, for every boundary
  boundary_on_error:               # per-boundary overrides
    after_memory_write: open
  evaluation_timeout_seconds: 5    # per policy; unset (no limit) by default

Every boundary defaults to closed, which is what each already did (the run or call failed); what changes is the shape of the failure and that it is recorded. Each handled error writes a policy.error audit record (outcome fail_closed or fail_open), adds to the algen_agent_runtime.policy.errors counter and logs policy_error with the error's type, never its text; the run's policy.evaluated event carries fail_mode and error_count. Alert on any fail_open record. Fail modes apply to the built-in CompositePolicyEngine; guards, approvals, injection screening and query governance always refuse.

A deny wins#

At one boundary a deny from any policy now wins over an earlier policy's require_approval or require_clarification: the later policies still run, so a call a bundle denies is refused instead of first being put to an approver. A deny at plan_tool now refuses the call; before, only require_approval was honoured there and a denial waited until before_tool, after an approval could have been asked for.

Signed policy bundles#

A bundle is one YAML or JSON file of declarative rules. Its name, version, issued_at and expires_at are inside the signed bytes. Each bundle runs as the policy bundle:<name> for every agent, whatever its guardrail_policy, so an agent definition cannot opt out.

acme-baseline.yaml · a policy bundle
format: 1
name: acme-baseline
version: 7                          # an integer; raise it for every release
issued_at: 2026-10-29T00:00:00Z
expires_at: 2027-01-31T00:00:00Z    # optional
rules:
  deny_tools: ["payments.*", "admin.*"]        # fnmatch globs; refused at plan_tool and before_tool
  require_approval_tools: ["crm.export"]       # a person approves first, whatever the side effect
  deny_patterns:
    - name: project-codename
      pattern: "(?i)project\\s+nightingale"
      boundaries: [input, after_model, final_response]
  redact_patterns:
    - name: employee-id
      pattern: "EMP-\\d{6}"
      replacement: "[EMPLOYEE_ID]"
      boundaries: [after_tool, after_retrieval, final_response]
sign in the release pipeline, not on Runtime hosts
$ algen-agent-runtime policy keygen --private-key signing.pem --public-key signing.pub
$ algen-agent-runtime policy sign acme-baseline.yaml --key env://POLICY_SIGNING_KEY
$ algen-agent-runtime policy verify acme-baseline.yaml --trusted-key file://signing.pub --min-version 7
agent.yaml · policies (bundles)
policies:
  require_signed: true
  trusted_keys:                      # Ed25519 public keys, env:// or file:// only
    - env://POLICY_PUBLIC_KEY
  bundles:
    - path: /etc/algen/policies/acme-baseline.yaml
      # signature: <path>.sig (the default)
      min_version: 7                 # refuse an older signed bundle put back in place
      # trusted_keys: [...]          # this bundle only

sign writes <bundle>.sig, a detached Ed25519 signature over the file's exact bytes; verify checks a bundle exactly as start-up does. Start-up refuses, and the container does not build, when require_signed is true and a bundle has no signature; when a signature does not verify against any trusted key (whatever require_signed says); when a bundle has expired, is issued more than five minutes in the future, or has a version below min_version; or when the file is invalid or two bundles share a name. With require_signed: false (the default) an unsigned bundle loads with a warning, for development. Each loaded bundle logs policy_bundle_loaded with its name, version, SHA-256 and key id. To rotate a key, add the new public key, sign with it, deploy, then remove the old one. CompositePolicyEngine.register(..., mandatory=True) adds a policy every agent runs from code.

Redaction and refused tool calls#

  • Bearer tokens and card numbers. The secrets policy also redacts bearer tokens (Bearer <token> with a digit or separator in the token), and the pii policy redacts card numbers as [REDACTED_CARD]: digit runs with a card network's prefix and length that pass the Luhn check. As with any redaction of tool arguments, such a value in a tool call's arguments refuses the call at before_tool. Pass credentials as env:// references or caller credentials, never as argument values.
  • UUIDs no longer refused. The pii policy no longer redacts digit runs inside UUIDs, hashes and other tokens as phone or Aadhaar numbers (an identifier must not touch a letter, digit, _ or -). Before, roughly one in sixty tool calls carrying a run, record or order UUID was refused at before_tool.
  • Wider redaction. redact() (audit metadata, logs, conversation records) also redacts key=value credential assignments; approval requests redact bearer tokens and key=value credentials inside argument values; the text of an error a tool raised is redacted for credentials; and opt-in telemetry content is redacted for emails, phone, Aadhaar and card numbers, and no longer carries a reply's credentials before after_model redacts it.

Behaviour changes. A policy that raises or times out now ends its boundary with a policy.error deny instead of its own exception propagating. A deny beats an approval requirement at the same boundary, and a deny at plan_tool refuses the call. A bearer token or card number in a tool call's arguments refuses the call. See Operations · policy boundaries, fail modes and signed bundles.

Govern

Human approvals#

Approvals are persisted before control returns, so a process restart never loses a pending decision. They appear in two places:

  • Tool approvals — a single-agent run reaches a side-effecting tool call, moves to awaiting_approval, and resumes with a decision.
  • Approval nodes — a workflow pauses at an approval node, then continues, skips dependents, or proceeds with modified parameters.

Another system can decide them too. A deployed service starts its workflows with POST /v1/workflows/{name}/runs, reads a run with GET /v1/workflows/runs/{id}, and answers its approval step with POST /v1/workflows/runs/{id}/approval (approved, rejected or modified). With workflow_notifications set, the service posts a signed event to that system's webhook when a run waits, carrying the step's prompt and what to review, never the run's values.

From 0.1.0a16 a conversation turn shows them in the chat: the assistant message waits in awaiting_approval or awaiting_input with a pending action, and the user answers on the turn. See Conversation turns.

running→ awaiting_approval→ approved · modified · rejected→ completed
support-desk — review.py
state = await executor.run(manifest, values, tenant_id="acme", user_id="jane")
if state.status is WorkflowStatus.AWAITING_APPROVAL:1
    print(state.pause["prompt"])
    state = await executor.decide_approval(
        manifest,
        state.id,
        tenant_id="acme",
        user_id="reviewer-7",2
        decision="modified",3
        modified_parameters={"change": "Enable credit for 30 days"},
        comment="Limited to 30 days per policy.",
    )
WorkflowApprovalDecisiondecided_by · decided_at recorded

Resolving a paused workflow in Python, or a paused run over HTTP (sample)

  • 1Pause — the persisted state carries the prompt and reviewable fields.
  • 2Attribution — the deciding user is recorded with the decision.
  • 3Decision — approved, modified (schema-validated edits), or rejected.

Approval nodes expire after expires_seconds (default 3600, maximum 7 days). rejection_policy: skip_dependents stops the downstream path; continue lets it proceed with the rejection recorded.

Approval inbox#

From 0.1.0a18 an approver lists what is waiting instead of polling runs:

RouteScopeReturns
GET /v1/approvalsapprovals:readThe tenant's approval requests, newest first. Query: status (pending, approved, rejected, modified, expired), run_id, agent, limit (1 to 500, default 50), offset (0 to 1,000,000).
GET /v1/approvals/{approval_id}approvals:readOne request; 404 for another tenant's.

The inbox shows every user's pending actions in the tenant, conversation turns included, so it needs the new approvals:read scope; runs:read is not enough. Grant it to approvers on purpose, for example through role_scopes. Each item is the stored ApprovalRequest (id, run_id, step_id, agent, proposed_action, side_effect_summary, redacted_parameters, risk, created_at, expires_at, status); modified_parameters are returned with the same redaction as redacted_parameters. A pending request past its expiry is reported as expired without changing the stored request. Decide as before with POST /v1/runs/{run_id}/approval, or on the conversation turn. In Python, runtime.approvals.list(tenant_id, status=..., run_id=..., agent=..., limit=..., offset=...).

Upgrade notes. Migration 015_approval_agent.sql adds an agent column and an index to the approvals table; requests stored by earlier releases have no agent, so an agent filter leaves them out. A host's own ApprovalService should add list(...) and the keyword-only agent argument to create. Paging is by offset, so de-duplicate by id. See Operations · approval inbox.

Breaking in 0.1.0a19 (governance fix). ToolExecutor.execute enforces requires_approval (@tool(..., approval=True), an MCP overlay's approval: true), not only when the model plans the call. A workflow tool: step on such a tool runs only after an upstream approval node was approved or modified in the same run, and a workflow with such a step and no approval node before it cannot start. Code that calls the executor directly passes approval=, a reference to the approval that covers the call. Agent runs are unaffected.

Runtime a17 · side-effect operations

Side-effect operations#

Every side-effecting tool call is reserved in the tool-execution ledger under its idempotency key (<run_id>:<tool_call_id>) before it runs. A call that fails without a clear outcome (a timeout, or a connection dropped after the request left) is recorded indeterminate; a call the process stopped in the middle of stays started. Neither is ever retried by itself. From 0.1.0a17 an operator can find these calls, record what happened, ask the tool, or undo the effect, and an agent can wait for that decision.

New in 0.1.0a17. Install it with python -m pip install "algen-agent-runtime==0.1.0a17". Studio's Runtime floor is still 0.1.0a13, so this applies only where 0.1.0a17 or later is installed. PostgreSQL ledgers get migration 014_tool_execution_reconciliation.sql on startup.

From 0.1.0a19 a tool that refuses before sending anything raises ToolNotAttemptedError (since 0.1.0a18) or PolicyDeniedError, and the call is recorded failed, not indeterminate: nothing needs reconciling, and it can be retried. algen-agent-tools 0.2.0a7 marks a provider's definite rejection (a 4xx other than 408, a refused SMTP recipient) the same way; timeouts and 5xx stay indeterminate.

What happens to the run#

By default (tool_policy.on_indeterminate: on_error) an indeterminate call is handled like any failed call: the run fails, or with on_error: return_to_model the model hears that it failed. Settling the call later is record-keeping only; an ended run is never reopened.

agent.yaml · wait for the operator
agents:
  - name: crm-assistant
    tool_policy:
      on_indeterminate: pause   # default: on_error

With pause the run pauses in the new awaiting_reconciliation status (a paused status, RunStatus.AWAITING_RECONCILIATION) with a tool.reconciliation_required event and a pause_payload of kind: tool_reconciliation naming the tool, call and idempotency key, never the arguments. Settling the call continues the run: completed gives the model the recorded result (screened like any tool result), or a note that the call took effect when no result was recorded; failed gives it a ToolCallNotApplied tool error; a successful compensation gives it ToolCallCompensated.

invoking_tool→ awaiting_reconciliation→ completed · failed · compensated→ run continues

A paused run keeps its remaining deadline and is not expired by the maintenance sweep; cancel it to give up. In a parallel batch the other calls are recorded first and the run pauses on the first unknown one. Read-only tools never pause a run, and a resumed run has no caller credentials, as after recovery. Workflow tool steps do not pause for reconciliation.

Operator routes#

RouteDoesBody
GET /v1/tool-executionsList the tenant's indeterminate and started calls, least recently changed first.Query: status (repeatable), run_id, tool, min_age_seconds, limit
GET /v1/tool-executions/{key}Read one call, with every earlier decision (resolutions).—
POST …/{key}/resolveRecord an operator's finding.{"status", "reason", "result"?, "result_reference"?, "expected_status"?, "resume"?}
POST …/{key}/reconcileAsk the tool's reconcile hook.{"reason"?, "resume"?}
POST …/{key}/compensateRun the tool's compensation.{"reason", "expected_status"?, "resume"?}
POST …/{key}/resumeContinue a run still awaiting_reconciliation on a call that is already settled.—

Every route needs the new tools:reconcile scope and acts only within the caller's tenant; another tenant's key is 404. The caller's user id is recorded as the actor, so grant the scope only to operators. The decision routes answer {"outcome", "run_resumed", "execution"}, and "resume": false settles a call without continuing its waiting run. Responses never carry the result value, only has_result and result_reference, the error type and resolutions. From Python, runtime.reconciliation (ToolReconciliation) has list, get, resolve, auto_reconcile, compensate and resume with the same rules, and returns full records, results included.

Resolve, reconcile and compensate#

ActionFromToNotes
resolveindeterminate · stale startedcompleted · failedreason is required. result only with completed, checked against the tool's output schema and size limit; result_reference names the effect in the external system.
resolvecompensatingcompensated · compensation_failedSettles a compensation the process stopped in the middle of, after checking the external system.
reconcileindeterminate · stale startedcompleted · failed, or unchangedThe hook's unknown changes nothing (outcome unknown). A hook that raises or times out is 400 and changes nothing; a tool without a hook is 409.
compensatecompleted · indeterminate · compensation_failedcompensating → compensated · compensation_failedRuns once per request; a tool without a compensation is 409.

Rules#

  • Compare-and-set. Each decision changes the ledger only if the status is still the one read, so of two concurrent decisions exactly one wins and the other gets 409. expected_status makes a request fail with 409 if the call changed since you looked.
  • Stale started calls. A started call may still be running. It can be resolved or reconciled only after ToolReconciliation.stale_started_after (default 10 minutes) without change; before that the answer is 409. Filter the list with min_age_seconds.
  • Late outcomes. The ledger's complete and fail change only a started record, so a call that finishes after an operator settled it cannot overwrite the decision; it is logged as tool_execution_late_outcome_ignored.
  • Record. Each decision appends {action, previous_status, status, actor_id, reason, result_reference, at} to the record's resolutions.
  • Re-execution. A call settled failed counts as not having happened: if a still-running run replays it, it runs again, as any failed call does.
  • Retention also keeps runs with compensating or compensation_failed tool executions.

The Tool.reconcile hook#

A tool can tell the operator, and the Runtime, whether a call took effect by looking up its idempotency key in the external system:

tools.py · reconcile and compensation hooks
from algen_agent_runtime.tools.contracts import ReconcileOutcome, ToolExecutionRecord


async def find_update(record: ToolExecutionRecord, context: ToolContext) -> ReconcileOutcome:
    change = await (await crm.client()).find_change(key=record.idempotency_key)
    if change is None:
        return ReconcileOutcome(status="unknown")   # or "failed" once the CRM says it never will
    if change.rejected:
        return ReconcileOutcome(status="failed", detail="rejected by the CRM")
    return ReconcileOutcome(status="completed", value=change.receipt(), result_reference=change.id)


async def undo_update(result: ToolResult, context: ToolContext) -> None:
    # result.value is None for an indeterminate call
    await (await crm.client()).revert_change(key=context.idempotency_key)


@crm.tool(write(approval=True), reconcile=find_update, compensation=undo_update)
async def update_owner(account_id: str, owner: str, context: ToolContext) -> Receipt:
    ...
  • reconcile answers completed (with value, which must fit the output schema), failed (it did not and will not happen) or unknown. It must only read, since it may run more than once, and it runs within the tool's timeout. Keep detail short and free of arguments and secrets: it is stored and audited.
  • Replay. When a recovered run replays a call that is still started or indeterminate, the executor asks the hook first (unless reconcile_on_replay=False): completed records the result and the run continues without running the tool again; failed fails the call without running it; unknown, an error or a timeout leaves the record, and the run fails or pauses as configured. The decision is recorded with actor runtime.
  • @tool and build_tool take reconcile= and compensation=; Tool(definition, handler, compensation, reconcile=..., reconcile_on_replay=True) takes the same hooks. Both hooks get the call's ToolContext without caller credentials, and describe shows reconcile: true and compensation: true. Built-in HTTP API, email and MCP tools have no reconcile hook yet.

Compensation#

Compensation runs only when an operator asks for it. The Runtime never compensates by itself, not on run failure, cancellation or recovery: it cannot tell whether a failure makes earlier effects wrong, a failure seen during recovery may be transient, and undoing an indeterminate call can itself cause a side effect when the original never happened. The ledger moves to compensating (compare-and-set) before the handler runs, so one request runs it at most once and a concurrent request gets 409. Success records compensated; an exception records compensation_failed with the error type, and only that status may be compensated again. If the process stops while the handler runs, the call stays compensating and is never compensated again by itself: settle it with resolve. For an indeterminate call the handler gets ToolResult(value=None, metadata={"execution_status": "indeterminate"}), so it must cope with an effect that may never have happened; make it idempotent with the idempotency key.

Runbook#

  1. Find the callsGET /v1/tool-executions, with min_age_seconds above the tool's timeout so calls still running are left out.
  2. Ask the tool, if it can tellPOST …/reconcile. unknown means look yourself.
  3. Or state the outcomePOST …/resolve with completed or failed, a reason, and expected_status set to what you read.
  4. Undo it, if it should not standPOST …/compensate with a reason.
  5. Continue a stuck runIf the Runtime stopped between settling a call and continuing its run, POST …/resume.

Events and audit records#

KindNameData
Run eventtool.reconciliation_requiredtool, idempotency_key, status (indeterminate or started)
Run eventtool.reconciledtool, idempotency_key, status, previous_status, method (operator, tool or replay)
Run eventtool.compensation.started · .completed · .failedtool, idempotency_key, status; error_type on failure
Run eventtool.completed with reconciled: trueWhen a waiting run continues; status completed or failed
Audittool.call, outcome awaiting_reconciliationstep_id, idempotency_key
Audittool.reconcile, outcome completed · failed · compensated · compensation_failed · unknown · error · resumedrun_id, step_id, idempotency_key, method, previous_status, reason, result_reference, error_type
Audittool.compensation, outcome started · completed · failedrun_id, step_id, idempotency_key, previous_status, reason, error_type

Events carry no arguments, results or reasons; reasons appear only in audit records and the ledger. Audit records name the operator as actor_id (runtime for replay-time reconciliation). The ledger is written before the run checkpoint, so a crash between the two can repeat tool.completed and tool.call: treat them as at-least-once and deduplicate by step_id.

Upgrade notes. Custom ToolExecutionStore implementations keep working, but reconciliation needs the ReconcilableToolExecutionStore methods (get, find, transition), which the in-memory and PostgreSQL ledgers implement. Clients that switch on RunStatus may see awaiting_reconciliation once an agent opts into on_indeterminate: pause. See Operations · resolving indeterminate tool calls and Tool authoring · unknown outcomes.

Govern

MCP servers#

In 0.1.0a2 the MCP client is experimental: remote tools are normalized into Runtime definitions and wired from Python over stdio and legacy sse, with namespaced registration and env:// secret references. Declarative configuration arrives in 0.1.0a3.

Since 0.1.0a3, MCP servers are declared in YAML and started with the container over stdio, streamable_http, or legacy sse. Discovered tools become governed ToolDefinitions like any other.

support-desk — agent.yaml
mcp_servers:
  - name: local-files
    transport: stdio
    command: npx1
    args: [-y, "@modelcontextprotocol/server-filesystem", /workspace]
    allowed_tools: [read_file, list_directory]2
    required: true

  - name: inventory
    transport: streamable_http
    url: https://mcp.example.com/mcp
    auth_token_ref: env://MCP_INVENTORY_TOKEN3
    allowed_tools: [lookup_item, update_stock]
    tool_policies:
      update_stock:4
        side_effect: write
        idempotency: non_idempotent
mcp_serversDNS-pinned safe egress

Declarative MCP servers (sample)

  • 1stdio command — trusted operator code, run on the host.
  • 2Allowlist — only listed tools are registered.
  • 3Secrets — tokens and sensitive headers must be env:// references.
  • 4Operator policy — outranks server annotations, which are distrusted by default.

Stdio servers are unsandboxed. stdio commands run as trusted operator code without process isolation. Declare only commands you control. Unannotated tools fall back to EXTERNAL and NON_IDEMPOTENT. See the MCP guide.

Stdio servers run on the host. stdio commands are trusted operator code. From 0.1.0a18 they can be confined with security.sandbox or a per-server sandbox (environment allowlist, resource limits, a wrapper such as bwrap); without a wrapper there is no filesystem or network isolation. Declare only commands you control. Unannotated tools fall back to EXTERNAL and NON_IDEMPOTENT. See Subprocess sandboxing and the MCP guide.

OAuth for remote MCP servers#

Hosted MCP servers such as Slack, Linear and Jira accept only OAuth access tokens. With auth: {type: oauth} on a streamable_http or sse server, the Runtime runs the authorization code flow with PKCE (S256), keeps the tokens per tenant or per user, refreshes them, and sends the access token on every request. It is off by default: servers without auth, and fixed auth_token_ref tokens, work as before.

agent.yaml · OAuth MCP server
storage:
  postgres_dsn: env://RUNTIME_POSTGRES_DSN
  mcp_oauth_token_store: postgres            # memory (default) or postgres
  mcp_oauth_encryption_key: env://MCP_TOKEN_KEY

mcp_servers:
  - name: linear
    transport: streamable_http
    url: https://mcp.linear.app/mcp
    auth:
      type: oauth
      client_id_ref: env://LINEAR_CLIENT_ID
      client_secret_ref: env://LINEAR_CLIENT_SECRET   # unset: a public client, PKCE alone
      scopes: [read, write]
      token_scope: user                       # one connection per user; tenant is the default
      redirect_uri: https://agents.example.com/v1/mcp/oauth/callback
      success_redirect_url: https://studio.example.com/settings/connections

Endpoints come from RFC 9728/8414 discovery, or set authorization_endpoint and token_endpoint (and optionally revocation_endpoint) for a provider that publishes no metadata. Other fields: token_endpoint_auth_method (client_secret_post or client_secret_basic), send_resource (RFC 8707, default true), authorize_params, state_ttl_seconds (600), refresh_margin_seconds (60), and discovery_tenant_id/discovery_user_id to list the server's tools at startup through a stored connection. Client secrets are env:// references. With token_scope: user, a run without a user_id is refused with PolicyDeniedError; it never falls back to another connection.

Connecting: the HTTP routes#

RouteScopeWhat it does
POST /v1/mcp/servers/{server}/oauth/startmcp:connect (+ mcp:admin for tenant scope)Returns authorize_url and expires_at; send the user's browser there.
GET /v1/mcp/oauth/callbacknone: the state is the credentialExchanges the code, stores the tokens, lists the server's tools if nobody has yet, then redirects to success_redirect_url or answers with the status.
GET /v1/mcp/servers/{server}/oauth/statusmcp:read or mcp:connectconnected, expires_at, scopes, refreshable, connected_at, connected_by.
GET /v1/mcp/oauth/serversmcp:read or mcp:connectThe caller's status for every OAuth server.
DELETE /v1/mcp/servers/{server}/oauthmcp:connect (+ mcp:admin for tenant scope)Removes the stored tokens, closes the caller's session, and revokes at the provider (RFC 7009) when it offers that.

No response carries a token. A tenant-scoped connection is shared by everyone in the tenant, so starting or revoking it also needs mcp:admin.

When a user has not connected#

A tool call on a server with no usable connection for the caller is answered to the model with a clean tool error, whatever tool_policy.on_error says, and the run emits an mcp.auth_required event with what a Connect button needs (and a tool.call audit with outcome not_connected):

{"error": {"type": "MCPNotConnected", "server": "linear",
           "message": "MCP server 'linear' is not connected for this user; ask the user to connect it, then try again"}}

mcp.auth_required → {"code": "mcp_not_connected", "server": "linear", "token_scope": "user",
                      "start_path": "/v1/mcp/servers/linear/oauth/start"}

Storage, encryption and limits#

  • storage.mcp_oauth_token_store: memory (the default) forgets every connection on restart, and a callback must reach the worker that started the flow: development and single-process deployments only.
  • postgres stores tokens and pending authorizations (migration 012_mcp_oauth.sql) as AES-256-GCM ciphertext bound to their tenant row. It needs the encryption extra (pip install 'algen-agent-runtime[encryption]') and storage.mcp_oauth_encryption_key, an env:// reference to a base64 32-byte key; rotate by listing the old key under mcp_oauth_previous_encryption_keys. Back the key up with the database: without it, users must connect again.
  • Tokens refresh refresh_margin_seconds before expiry and once on a 401; a refresh the provider rejects removes the connection. Metadata, token and revocation requests use the SSRF-guarded client, so add the authorization server's hosts to security.allowed_http_hosts.
  • At startup an OAuth server usually has no connection to list tools with, so it is recorded in degraded_dependencies (startup does not fail, even with required: true) and its tools are listed when the first connection completes.

Limits. Dynamic client registration (RFC 7591) is not supported: register a client with the provider and configure its client_id. Run more than one API worker only with the PostgreSQL store. Tokens, refresh tokens, PKCE verifiers and client secrets never reach tool code, the model, logs, events, spans or audit records. See OAuth for remote MCP servers.

Runtime a18 · network and plugin hardening

Egress, sandbox & signing#

From 0.1.0a18 every outbound client the Runtime builds can go through one egress proxy with per-host TLS identities and pins, child processes can be confined, and plugin packages can be verified before any of their code is imported. The defaults change nothing.

New in 0.1.0a18. Install it with python -m pip install "algen-agent-runtime==0.1.0a18"; plugin signing needs the encryption extra. Studio's Runtime floor is 0.1.0a17, so this applies only where 0.1.0a18 or later is installed.

Egress proxy#

agent.yaml · network.egress_proxy
network:
  egress_proxy:
    url: http://egress-proxy.internal:3128   # or env://EGRESS_PROXY_URL; no credentials here
    username: env://EGRESS_PROXY_USER        # optional, sent as Proxy-Authorization (Basic)
    password: env://EGRESS_PROXY_PASSWORD
    no_proxy: [localhost, 127.0.0.0/8, .svc.cluster.local]   # reached directly
    resolve_targets: true
  • Covers core.http, http_tools, remote_tool, MCP HTTP transports and OAuth, OIDC discovery and JWKS, workflow notifications, HTTPModelService and the HTTP model providers. The S3 client, OTLP and Traccia exporters and vector-store SDKs keep their own proxy settings (normally HTTPS_PROXY).
  • TLS end to end. https:// targets go through a CONNECT tunnel; http:// targets are forwarded.
  • Checked first. Each target passes the allowlist and private-network checks before the proxy is contacted, and the proxied pool connects only to the proxy. Proxy credentials are env:// references and never appear in URLs, logs or errors.
  • Limit. The proxy makes the final connection and resolves the name again, so DNS pinning protects only up to it: give the proxy its own egress policy. resolve_targets: false checks only the allowlist and IP literals, for a Runtime that cannot resolve external names.

mTLS, private CAs and pinning#

agent.yaml · network.remotes
network:
  remotes:
    api.partner.example:                              # or "*.domain" for its subdomains
      ca_bundle: /etc/algen/partner-ca.pem            # replaces the default trust store
      client_certificate: env://PARTNER_CLIENT_CERT   # PEM text, or a file path
      client_key: env://PARTNER_CLIENT_KEY
      client_key_password: env://PARTNER_KEY_PASSWORD # only for an encrypted key
      pinned_sha256: ["5F:3A:...:9C"]                 # leaf certificate SHA-256

The settings apply to every client above, direct or through the proxy. Host name verification stays on; a pin mismatch closes the connection with PolicyDeniedError. Keys and certificates are file paths or env:// references (literal PEM is refused) and are never logged. Host code can build the same clients with create_safe_http_client(outbound=...) or create_outbound_http_client; a built container exposes container.outbound.

Behaviour changes. core.http in a built container now uses the SSRF-safe, DNS-pinned client (it used a plain client after validating the URL), so it refuses private and internal addresses unless network settings allow them, and every container client honours network. A proxy= passed to create_safe_http_client is refused unless it is the configured network.egress_proxy, and mounts= is refused: before, it could route requests around the allowlist.

Subprocess sandboxing#

security.sandbox controls MCP stdio servers and core.subprocess; a stdio server's own sandbox replaces it for that server.

SettingDefaultEffect
env_allowlistunsetVariables a child inherits. Unset, stdio servers keep the basic set they always had and core.subprocess inherits nothing; a server's explicit env is always passed.
working_directoryunsetWorking directory for children that set none.
limitsnonePOSIX limits: cpu_seconds, memory_bytes (Linux only), open_files, processes, file_size_bytes.
wrappernoneA command prefix that isolates filesystem and network: bwrap, nsjail, firejail, sandbox-exec.
requiredfalseRefuse to start any stdio server or core.subprocess call without a wrapper.
subprocess_timeout_seconds60core.subprocess wall-clock limit; on timeout or cancellation the whole process group is killed.
agent.yaml · security.sandbox (Linux, bubblewrap)
security:
  sandbox:
    required: true
    env_allowlist: [PATH, LANG]
    limits: {cpu_seconds: 600, open_files: 1024, file_size_bytes: 104857600}
    wrapper: [/usr/bin/bwrap,
              --ro-bind, /usr, /usr,
              --symlink, usr/lib, /lib,
              --symlink, usr/lib64, /lib64,
              --symlink, usr/bin, /bin,
              --ro-bind, /opt/mcp-servers, /opt/mcp-servers,
              --proc, /proc, --dev, /dev, --tmpfs, /tmp,
              --unshare-all, --die-with-parent, --new-session, --]

Limits and a scrubbed environment are not isolation: without a wrapper a child reads every file and reaches every host the Runtime's user can. See Operations · sandboxing for complete wrapper examples.

Plugin signing#

sign as the last build step
$ algen-agent-runtime plugin keygen signing-key.pem   # prints the public key
$ algen-agent-runtime plugin sign src/acme_plugins --publisher acme --key env://ACME_PLUGIN_SIGNING_KEY --name crm --version 1.2.0
$ algen-agent-runtime plugin verify acme_plugins.crm:CrmPlugin --config agent.yaml
agent.yaml · plugins
security:
  trusted_plugin_prefixes: [acme_plugins]
plugins:
  trusted_publishers:
    acme: ["MCowBQYDK2VwAyEA..."]   # base64 raw Ed25519 public keys; several allow rotation
  require_signed: true

plugin sign writes algen-plugin-signature.json into the package: the publisher, the bound name and version, the package name and the SHA-256 of every file, so any changed, added or removed file fails verification. The package is located and verified before it is imported, for domain_plugins and workflow hook packages. With require_signed: true an unsigned package, or one from a publisher not in trusted_publishers, is refused even under a trusted prefix; without it, signatures from trusted publishers are still verified and unsigned plugins load as before. The signature does not cover dependencies or __pycache__ and cannot stop a file being replaced after verification: install plugin packages read-only. PluginManifest.signature is unused. See Operations · plugin signing.


Operate

Execution model#

The native runtime owns legal state transitions. Strategies can be replaced, but they cannot bypass the execution contract: input policy runs before planning, model routing follows capability discovery, side effects pass through the governed tool executor, and output verification is bounded. Explore every boundary in the architecture map.

01Validate→ 02Prepare→ 03Execute→ 04Verify→ 05Commit
PhaseWhat happensRunStatus values
ValidateIdentity, request, and input policy.received · validating
PrepareContext building and a strict plan.building_context · planning
ExecuteModel routing and governed tool calls; may pause for a human.invoking_model · invoking_tool · awaiting_approval · awaiting_clarification
ExecuteModel routing and governed tool calls; may pause for a human, or for an operator to settle a call whose outcome is unknown.invoking_model · invoking_tool · awaiting_approval · awaiting_clarification · awaiting_reconciliation
VerifyOutput policy, verifiers, and bounded repair.verifying · retrying
CommitCompose response, checkpoint, audit.composing → completed · failed · cancelled · timed_out

Durability is the source of truth. Active asyncio tasks are an optimization. Checkpoints, versions, tool reservations, approval states, and workflow fingerprints define recoverable execution; incomplete runs are recovered on start.

Steering a running run#

From 0.1.0a18 a user can add to a run while it works. runtime.steer(run_id, tenant_id, text), or POST /v1/runs/{run_id}/steer with {"text": "..."} (runs:write; for a conversation turn's run, only the conversation's owner), queues a message and answers 202 with {"run_id", "steer_id", "status": "queued"}.

  • Delivery. Before the next model call, after the results of the model's last reply, as one user message, checked at the input policy boundary. A steer.delivered event and a run.steer audit record follow.
  • Pauses. Queued messages are checkpointed in RunState.pending_steering, so they survive an approval, clarification or reconciliation pause and are delivered after the resume.
  • Run ends first. Messages still waiting are RunResult.undelivered_steering, announced by steer.undelivered, so the host can hand them back or start the next turn with them.
  • Limits. At most 16 waiting messages of 8,000 characters each. Empty or longer text is 422; a full queue, an ended run or a run another process is driving is 409.

Events carry count and messages ({id, chars}); the text only with content telemetry on. Conversation turns do not expose steering yet: steer the turn's run by its id (pending.run_id). See Operations · steering.

Operate

State & persistence#

Ports keep execution semantics independent from infrastructure. In-memory adapters are the default for local use and deterministic tests; PostgreSQL and Redis cover durable records, checkpoints, leases, and expiring session state.

support-desk — config/production.yaml
storage:
  run_store: postgres
  memory_store: redis1
  event_store: postgres
  audit_store: postgres
  approval_store: postgres
  artifact_store: postgres
  tool_execution_store: postgres
  conversation_store: postgres
  postgres_dsn: env://ALGEN_AGENT_RUNTIME_POSTGRES_DSN2
  redis_url: env://ALGEN_AGENT_RUNTIME_REDIS_URL
  initialize_schema: true
  postgres_min_pool_size: 2
  postgres_max_pool_size: 10
requires [postgres,redis]secrets via env://

Durable stores (from examples/case_study_teaching_assistant)

  • 1Per-store backends — mix PostgreSQL, Redis, and memory per concern.
  • 2Connection secrets — DSNs are env:// references, never literals.
LayerDefaultOptional adapter
Run records & eventsIn memoryPostgreSQL
Checkpoints & sessionsIn memoryRedis · PostgreSQL
Approvals, audit, tool executionsIn memoryPostgreSQL
Artifact metadataIn memoryPostgreSQL
Artifact bytesIn memoryEncrypted S3-compatible storage
RetrievalHashing embedderRegistered vector-store adapters

Artifacts move through pending_scan → available or quarantined with SHA-256 identity and retention; see Artifact lifecycle.

Sharing a database: postgres_schema#

From 0.1.0a18 storage.postgres_schema keeps the Runtime's tables in their own schema, so they can share a database with an application's own. Every pooled connection starts with search_path set to that schema alone, so migrations and every PostgreSQL store use it; with initialize_schema it is created when missing. Start-up fails if the schema is missing (without initialize_schema), not usable by the role, or not in effect, for example behind a pooler in transaction mode that drops the startup parameter (with PgBouncer 1.20 or later add search_path to track_extra_parameters, or use session pooling). Unset by default: existing deployments are unchanged. The Runtime does not move existing tables; if the schema is empty while public still holds them, start-up logs postgres_schema_is_empty_but_public_has_runtime_tables. The pgvector retriever keeps its own table and is not affected.

Store outages#

SettingDefaultBounds
storage.postgres_connect_timeout_seconds10Opening a PostgreSQL connection.
storage.postgres_command_timeout_seconds60One PostgreSQL store command; null waits indefinitely. Migrations are exempt.
storage.redis_socket_timeout_seconds10Connecting to Redis and one Redis command; null waits indefinitely.
  • 503, not 500. PostgreSQL and Redis store calls fail with StoreUnavailableError (kind: unavailable, retryable) for connection, timeout and server-shutdown errors, and the API answers 503 with Retry-After. Starting a run, deciding an approval or answering a turn during an outage is refused this way; /health/ready reports the store down.
  • Runs in flight survive. A run whose store becomes unavailable keeps its last checkpoint and is driven again once the store answers (backoff from 0.5 to 30 seconds), with the same caller credentials; runtime.wait follows it. If the process stops first, the next start recovers it.
  • Reconnect. After a PostgreSQL restart or failover only the first call fails: the pool replaces all its dead connections at once.
  • Concurrent drives. A drive whose checkpoint write loses the version check (RunConflictError, a ConflictError, for example because another process recovered the run) stops without writing instead of failing the run. A recovered run no longer counts the interrupted step twice, and resuming right after an approval pause waits for the drive that paused it instead of getting stuck.
  • Store combinations. event_store, approval_store or tool_execution_store postgres now require run_store: postgres; their rows reference the run in PostgreSQL. Configuration rejects other combinations.

Run one Runtime process per database (or schema): recovery is not coordinated between processes, and a model call may be made twice when two drive the same run. The tests/drills suite (marker drill) checks this contract against live PostgreSQL and Redis: crashes mid-model and mid-tool call, two processes deciding one approval, outages, a database restart, pg_dump backup and restore, and upgrade from and rollback to 0.1.0a17.

Upgrades and rollback#

Migrations are forward-only and additive, and run under a PostgreSQL advisory lock, so replicas starting at once apply them one at a time. Stored approvals, checkpoints and tool results keep the 0.1.0a17 shape unless a new feature is used (a pending steering message, a failed tool result), so to roll back, start 0.1.0a17 on the same database and leave the new migrations in place.

MigrationChangeSafe to leave on rollback
015_approval_agent.sqlNullable agent column and an index on approvals.Yes
016_resource_bounds.sqlPer-tenant storage usage table, triggers on artifact, memory and learned-memory writes, retention indexes.Yes; 0.1.0a17 writes still fire the triggers.

Upgrade notes. Migration 016 locks the artifact, memory and learned-memory tables against writes until its backfill commits: on large tables run the upgrade in a quiet period and create its indexes CONCURRENTLY beforehand. Take a backup first. Clients that retry only on 500 should also retry on 503. A PostgreSQL command that needs longer than 60 seconds needs postgres_command_timeout_seconds raised. See Operations · persistence.

Runtime a18 · resource bounds

Limits, quotas & retention#

From 0.1.0a18 one run cannot make the model produce, or record, without bound; each tenant's stored artifacts and memory can be capped; and the remaining growing tables have retention windows.

New in 0.1.0a18. Install it with python -m pip install "algen-agent-runtime==0.1.0a18". Studio's Runtime floor is 0.1.0a17, so this applies only where 0.1.0a18 or later is installed. The run-limit defaults are far above a normal run; quotas and the new retention windows are off by default.

Run limits#

SettingDefaultBounds and what happens
budget.max_model_calls1,000Model calls in the run: steps, retried rejections, compaction and repairs. A continuation adds step_increment; the wrap-up notice counts it (reason: model_calls).
budget.max_response_chars1,000,000One model response: text plus tool-call arguments. Enforced while a response streams; the run fails at once.
budget.max_events100,000Run events recorded. Past it only events that end or pause the run are recorded, and the run fails at its next step.
runtime.stream_delta_interval_ms50Streamed text is recorded as at most one model.delta event per interval, so long streams do not fill the event store. 0 records every chunk, as AgentRuntime constructed directly does.

A run that ends at a limit fails with run.failed reason: limit_exceeded and limit (model_calls, response_chars, events, or the existing step, token and cost limits, which now report the same way), a run.limit_exceeded audit record and the agent.run.limit_exceeded span attribute. Alert on it: a run that keeps reaching a limit is looping or being fed hostile input.

Storage quotas#

agent.yaml · storage.quotas
storage:
  quotas:
    default:                         # every tenant; unlimited when unset
      artifact_count: 10000
      artifact_bytes: 10737418240    # 10 GiB
      memory_items: 100000           # session memory messages
      memory_bytes: 1073741824
      learned_memory_items: 5000
      learned_memory_bytes: 52428800
    tenants:
      enterprise-tenant: {artifact_bytes: 107374182400}
      internal-tools: {memory_items: null}   # null lifts a limit for this tenant
  • Enforced on write by the in-memory, PostgreSQL, S3 and (when a quota is set) Redis stores. A write past a limit writes nothing and raises QuotaExceededError; the API answers 413 with code: QUOTA_EXCEEDED, resource, dimension, limit and used. Retrying cannot succeed until the tenant deletes data or the quota is raised. Deletes are always allowed.
  • Inside a run a full artifact quota fails the tool-result offload as a tool error, a full learned-memory quota fails the memory tool call, and a full session-memory quota keeps the answer but skips the turn's memory (memory.written with operation: skipped, a storage.quota_exceeded audit record).
  • Usage. GET /v1/storage/usage (new scope usage:read) and container.storage_usage report the tenant's usage and limits. On PostgreSQL, migration 016's triggers keep algen_agent_runtime_tenant_usage current in the same transaction as every write, and a quota-checked write locks the tenant's row, so concurrent writes cannot overshoot. Redis quotas need a Redis that is not clustered.

Retention#

Setting (maintenance.)Deletes, when not updated within the window
conversation_retention_daysConversations with their messages, events and feedback; kept while a turn is in progress or waiting, or a message is newer than the window.
workflow_checkpoint_retention_daysCompleted, failed and cancelled workflow runs, except a child whose parent is unfinished.
analytical_graph_retention_daysCompleted, failed and cancelled analytical graphs.
work_item_retention_daysCompleted and dead work items; their deduplication keys are released.

All are off by default and apply to PostgreSQL and in-memory stores, in batches like the existing purges; migration 016 adds the supporting indexes. Choose windows that cover incident investigation and compliance, and back up first. See Operations · run limits, storage quotas and retention.

Operate

Events & observability#

Every run emits an ordered stream of typed RunEvents carrying run, session, correlation, workflow, and OpenTelemetry trace identifiers. Stream them in-process, over HTTP as server-sent events, or persist them to a store.

Run eventsagent: support-assistantcompleted
1run.startedtenant acme · user jane0 ms
2step.startedplan step 14 ms
3model.startedprovider local · model deterministic6 ms
4model.completedrequests tool text.word_count31 ms
5policy.evaluatedauthorization · allow1
6tool.startedside_effect none · idempotent33 ms
7tool.completedresult redacted before export35 ms
8verification.completedverifier non_empty · pass2
9run.completedusage and cost in execution_summary3

One run's event stream (sample)

  • 1Policy decisions — each boundary emits its own evidence.
  • 2Verification — bounded repair is visible, not hidden.
  • 3Terminal event — run.completed, run.failed, or run.cancelled ends the stream.
support-desk — stream.py
client = AlgenAgentRuntimeClient(container.runtime)
request = RunRequest(agent="support-assistant", input="Hello", tenant_id="acme", user_id="jane")

async for event in client.stream(request):
    print(event.sequence, event.type, event.data)
OpenTelemetry · OTLPredaction before export

Streaming events in-process, and telemetry with the optional Traccia integration (sample)

Event types#

GroupTypes
Runrun.started · step.started · run.completed · run.failed · run.cancelled
Modelmodel.started · model.delta · model.completed · model.retrying
Tools & contexttool.started · tool.completed · context.retrieved · memory.written
Context & learned memory (a7)context.compaction.assessed · context.compacted · context.working_state.updated · context.skills.updated · memory.learned.updated
Guards, metering & MCP OAuth (a14)tool.refused · tool.repeated · tool.wrap_up · budget.refused · mcp.auth_required
Metering refusal data (a15)budget.refused event and audit record: reason · provider · model
Governancepolicy.evaluated · verification.completed
Humanapproval.required · clarification.required
Workflow waits (a16)workflow.input.expired, beside workflow.approval.expired and workflow.recovered; see Service workflow lifecycle
Side-effect operations (a17)tool.reconciliation_required · tool.reconciled · tool.compensation.started · tool.compensation.completed · tool.compensation.failed; see Side-effect operations
Authentication (a17)Audit records, not run events: auth.authenticate · auth.audit.suppressed · auth.token.revoked · auth.subject.revoked; see Authentication
Steering, notes & limits (a18)steer.delivered · steer.undelivered · tool.note; run.failed with reason: limit_exceeded; policy.evaluated carries fail_mode and error_count; see Steering and Run limits
Audit records (a21)model.tool_call_invalid (malformed · cut_off); metric algen_agent_runtime.model.tool_calls_invalid by provider and model
Audit records (a18)policy.error (fail_closed · fail_open) · run.limit_exceeded · storage.quota_exceeded · run.steer; metric algen_agent_runtime.policy.errors
Conversation turns (a16)Conversation events, not run events: assistant.message.delta · assistant.message.awaiting_approval · assistant.message.awaiting_input · assistant.message.resumed · assistant.message.expired; see Conversation turns

Audit is not logging. Diagnostic logs can change for operations. AuditEvent records are stable evidence contracts with actor, action, outcome, resource, and tenant attribution.

Complete traces and the run narrative#

From 0.1.0a11, with Traccia enabled, every Runtime span is created through Traccia's tracer, so its enrichment (agent identity, tokens, cost, guardrail detection) covers model, tool and step spans, and each run's spans are exported together once the run ends, so a backend never receives part of a run. The root records agent.run.span_count, and a run resumed after an approval continues its first trace.

SpanSignals, without content
agent.runoutcome, step, model and tool counts, tokens, input and output sizes, span count; a cost only when provider rates priced it
agent.model.callrequested tool names, time to first token, time spent before the call, uncached prompt tokens with cache reads and writes beside them
agent.tool.callargument keys, status, error type, result size

GET /v1/runs/{run_id}/narrative returns the run in order (model calls and the tools they requested, tool calls, approvals, policies that acted, verification, outcome, totals and trace id) from stored state and events, with no prompt or answer text: the input to hand an analyst or another agent.

Only Runtime traces reach Traccia#

Behaviour change in 0.1.0a14. With Traccia on, Traccia receives only the traces the Runtime started. Spans that other libraries create through the global OpenTelemetry provider (FastAPI's request tracing, httpx or SQLAlchemy instrumentors), which Traccia filed under unknown-agent-name, are dropped.

A trace is kept when the span that opened it in this process is a Runtime root: conversation.turn, an agent.run that opens its trace (now marked algen.runtime.root=true), or a span from a Runtime tracer (algen_agent_runtime.*), so Runtime spans without a run, such as model services and feedback, still go out. A run started inside an HTTP request now opens its own trace instead of becoming the request's child, so it is exported whole. This works with and without export_whole_runs; an exporter added after Traccia starts, such as telemetry.otlp_endpoint, is not filtered.

telemetry.yaml · Traccia
telemetry:
  traccia:
    enabled: true
    export_foreign_traces: false   # default: only traces the Runtime started
# true (or ALGEN_AGENT_RUNTIME__TELEMETRY__TRACCIA__EXPORT_FOREIGN_TRACES=true)
# sends everything as before, with runs as children of the request that started them

Upgrade note. An application that turned off FastAPI's request tracing to keep it out of Traccia can remove that workaround. Other OpenTelemetry deployments can use install_runtime_trace_filter(provider) from algen_agent_runtime.observability.runtime_traces. See Operations · Traccia.

Exporting metrics over OTLP#

telemetry.yaml · OTLP metrics
telemetry:
  otlp_metrics_endpoint: https://collector:4318/v1/metrics
  otlp_metrics_export_interval_seconds: 60   # default; 1 to 3600

From 0.1.0a18 the Runtime's OpenTelemetry metrics can be exported to an OTLP/HTTP collector through a periodic reader, and the API flushes them on shutdown. If an SDK meter provider is already installed (by Traccia or the application), it is kept and the setting is ignored with a warning. telemetry.otlp_endpoint no longer installs a meter provider with no reader: before, every Runtime metric was recorded and silently dropped, and an application could not install its own provider afterwards.

Operate

Serve over HTTP#

The same container runs as a FastAPI service. Runs are accepted asynchronously and observed through status reads or an event stream.

zsh
$ algen-agent-runtime serve --config agent.yaml
$ curl -s http://127.0.0.1:8000/v1/runs -H 'Content-Type: application/json' -H 'X-Tenant-ID: acme' -H 'X-User-ID: jane' -H 'X-Scopes: runs:write runs:read' -d '{"agent": "support-assistant", "input": "Hello"}'
{"run_id": "…", "session_id": "…", "status": "…"}
$ curl -N http://127.0.0.1:8000/v1/runs/$RUN_ID/events -H 'X-Tenant-ID: acme' -H 'X-User-ID: jane' -H 'X-Scopes: runs:read'
EndpointPurposeScope
POST /v1/runsStart a run (202). Body: agent, input, optional session_id, timeout_seconds, metadata, overrides.runs:write
GET /v1/runs/{run_id}Read run state.runs:read
DELETE /v1/runs/{run_id}Cancel a run.runs:write
POST /v1/runs/{run_id}/approvalResume with decision and optional modified_parameters.runs:write
POST /v1/runs/{run_id}/clarificationResume with a clarification answer.runs:write
GET /v1/runs/{run_id}/eventsServer-sent events; resumable with Last-Event-ID.runs:read
/v1/artifacts · /v1/conversationsArtifact staging and multi-turn conversations.per route
POST /v1/conversations/{id}/turns/{turn_id}/approval · /inputAnswer a conversation turn that waits; see Conversation turns.conversations:write
GET /v1/tool-executions · /{key} · POST …/resolve · /reconcile · /compensate · /resumeSettle tool calls whose outcome is unknown; see Side-effect operations.tools:reconcile
POST /v1/auth/revocations/tokens · /subjectsRevoke a token or a subject's tokens (404 while revocation is off); see Authentication.auth:revoke
POST /v1/runs/{run_id}/steerQueue a message for a run that has not ended (202); see Steering.runs:write
GET /v1/approvals · /{approval_id}The tenant's approval inbox; see Approval inbox.approvals:read
GET /v1/storage/usageThe tenant's storage usage and quotas; see Storage quotas.usage:read
GET /v1/agentsList registered agents.—
GET /health/live · /health/readyLiveness and readiness probes.—
GET /health/live · /health/readyLiveness, and readiness with every dependency named; 503 only when a required one is down or the service is draining. See Readiness & lifecycle.—

Development headers are for loopback only. The default api.auth_mode: development_headers trusts X-Tenant-ID, X-User-ID, and X-Scopes and refuses to bind to a non-loopback host. For anything shared, install [auth] and set auth_mode: jwt with jwt_key: env://….

Development headers are for loopback only. The default api.auth_mode: development_headers trusts X-Tenant-ID, X-User-ID, and X-Scopes and refuses to bind to a non-loopback host. For anything shared, install [auth] and set auth_mode: oidc with your identity provider, or auth_mode: jwt with jwt_key: env://…. See Authentication.

Tenant-aware rate limits return 429 with Retry-After; oversized request bodies return 413, enforced on actual wire bytes.

From 0.1.0a18 a store outage answers 503 with Retry-After instead of 500 (see Store outages), and a write past a tenant's storage quota answers 413 with code: QUOTA_EXCEEDED (see Storage quotas).

Conversation ownership on run routes. From 0.1.0a18 every /v1/runs/{run_id} route (read, narrative, events, cancel, clarification, approval and steer) answers 404 to any user of the tenant other than the conversation's owner when the run was started for a conversation turn, whatever their scopes. Before, any runs:write principal in the tenant could decide or cancel another user's conversation-turn run, and any runs:read principal could read it, given its id. Runs started directly with POST /v1/runs are checked against the tenant only, as before.

Startup banner#

From 0.1.0a21 serve prints a banner when its output is a terminal: the Algen logo, the agent and version, its model, enabled tools, active guards, and the API and UI URLs. It never shows secrets or env:// references. --no-banner turns it off and --banner prints it to a log too; NO_COLOR and FORCE_COLOR are honoured. An application that runs its own server prints the same banner with its own name and rows:

app.py · before uvicorn.run
from algen_agent_runtime.banner import print_startup_banner

print_startup_banner(settings, title="ms-email-test", tagline="a governed Outlook agent",
                     details={"mailbox": "fake (in-process Graph)"})
Runtime a17 · authentication

Authentication#

api.auth_mode decides how the service knows who is calling. From 0.1.0a17 it can verify tokens from an OpenID Connect identity provider, revoke tokens before they expire, accept a client-certificate identity from a trusted proxy, and audit every failed sign-in.

New in 0.1.0a17. Install it with python -m pip install "algen-agent-runtime[auth]==0.1.0a17"; jwt and oidc need the auth extra (PyJWT[crypto]). Studio's Runtime floor is still 0.1.0a13, so this applies only where 0.1.0a17 or later is installed.

ModeUseVerification
development_headers (default)Local development onlyTrusts X-Tenant-ID, X-User-ID, X-Scopes. Refused on a non-loopback host unless allow_insecure_development_auth: true.
jwtOne signing key you manageBearer JWT verified with the static env:// jwt_key, jwt_algorithms, jwt_issuer and jwt_audience; tenant from tenant_claim, scopes from scopes_claim. Unchanged, except as noted below.
oidcAn OpenID Connect identity providerBearer JWT verified against each issuer's published JWKS, with key rotation and claim mapping.

OIDC issuers and JWKS#

agent.yaml · api.auth_mode: oidc
api:
  host: 0.0.0.0
  auth_mode: oidc
  oidc:
    jwks_cache_ttl_seconds: 300            # how long fetched keys are used (30 to 86400)
    jwks_min_refresh_interval_seconds: 30  # at most one JWKS fetch per issuer per interval
    http_timeout_seconds: 5
    allow_private_networks: false          # true only for an in-cluster identity provider
    issuers:
      - issuer: https://login.example.com/realms/acme   # exact 'iss'
        # jwks_url: https://login.example.com/realms/acme/protocol/openid-connect/certs
        audiences: [algen-agent-runtime]
        algorithms: [RS256]                # RS*, PS*, ES*, EdDSA only
        leeway_seconds: 30                 # clock skew for exp/nbf/iat, at most 300
        max_token_lifetime_seconds: 900    # optional: refuse long-lived tokens
        required_claims: [azp]             # beyond exp, iss, aud and the subject
        claims:
          subject_claim: sub
          tenant_claim: tenant_id          # or fixed_tenant: acme
          allowed_tenants: []              # optional allowlist
          scope_claims: [scope, scp]
          role_claims: [realm_access.roles, groups]
          role_scopes:
            runtime-operators: [runs:read, runs:write, conversations:read]
            runtime-admins: [auth:revoke, tools:reconcile]
  • Discovery. Without jwks_url the Runtime reads {issuer}/.well-known/openid-configuration, requires its issuer to equal the configured one, and fetches its jwks_uri.
  • Egress. Discovery and JWKS fetches use the SSRF-safe client and reach only the issuer host, the jwks_url host and the issuer's additional_allowed_hosts. Private, loopback, link-local and metadata addresses are refused unless allow_private_networks: true; redirects are not followed; responses are capped at max_jwks_bytes (256 KiB); https is required unless allow_insecure_http: true (local test providers only).
  • Caching and rotation. Keys are cached for jwks_cache_ttl_seconds. A token with an unknown kid triggers a refresh, at most one per jwks_min_refresh_interval_seconds per issuer, so random key ids cannot turn requests into identity-provider traffic. If a refresh fails the last good keys stay in use; if keys were never fetched the API answers 503. Publish a new key before signing with it, and keep the old one published until its tokens have expired.
  • Algorithms. Only the issuer's algorithms are accepted; none and HS* are refused by configuration validation. The token's algorithm must also match the key type (RSA: RS*/PS*, EC: ES*, OKP: EdDSA) and the key's alg when the JWKS states one. Symmetric (oct) keys are ignored, and tokens must carry a kid.
  • Claims. exp, iss, aud and the subject are required; nbf and iat are checked when present, all with leeway_seconds of skew. max_token_lifetime_seconds (60 to 86400) requires iat and refuses tokens that live longer.

Claims to tenant and scopes#

The subject claim becomes the user id. The tenant comes from fixed_tenant (one-tenant issuers; it takes precedence) or tenant_claim (default tenant_id), and allowed_tenants can limit it. A missing, empty or non-string tenant is refused: the tenant fails closed and is never defaulted. Scopes are the union of the scope_claims values (default scope and scp; a space-separated string or a list) and the scopes role_scopes grants for each value in role_claims (default roles and groups). A * in a token's own scope claim is dropped; grant the wildcard through role_scopes if you really need it. Claim names are looked up literally first, for namespaced claims such as https://example.com/tenant, then as dotted paths such as realm_access.roles.

Token revocation#

Short-lived access tokens (5 to 15 minutes, enforced with max_token_lifetime_seconds) are the main revocation control. For a token that must stop working sooner, turn on the check, for jwt and oidc:

agent.yaml · api.token_revocation
api:
  token_revocation:
    enabled: true
    store: postgres     # 'memory' (default) is per process and lost on restart; postgres needs storage.postgres_dsn
    require_jti: false  # true refuses tokens without a jti

Each verified token is then checked, in one store lookup, against a tenant-scoped jti denylist and a per-subject “revoked before” cutoff (a token whose iat is at or before the cutoff, or that has no iat, is refused). A store outage fails closed with 503. Revoke with the auth:revoke scope, within the caller's own tenant:

RouteBodyEffect
POST /v1/auth/revocations/tokens{"jti", "expires_at", "reason"?}Denies the token until its exp; the entry is purged afterwards. expires_at at most a year ahead.
POST /v1/auth/revocations/subjects{"subject", "revoked_before"?, "reason"?}Refuses every token for the subject issued at or before the cutoff (default now, at most five minutes in the future). A cutoff only moves forward.

Both return 404 while revocation is off. From Python use container.token_revocations (revoke_token, revoke_subject, state, purge_expired). The PostgreSQL store uses migration 013_token_revocation.sql, run on startup. The check costs one indexed lookup per request and makes the store a dependency of authentication.

Service identity from a trusted proxy#

When a TLS-terminating proxy verifies client certificates, it can pass the client's identity to the Runtime in a header. This is off by default and needs auth_mode: jwt or oidc.

agent.yaml · api.trusted_proxy_identity
api:
  trusted_proxy_identity:
    enabled: true
    header: x-client-cert-identity
    trusted_proxies: [10.0.0.0/24]       # CIDRs of the proxies, never 0.0.0.0/0
    identities:
      spiffe://acme/ns/jobs/sa/scheduler:
        tenant_id: acme
        user_id: svc-scheduler           # default: the identity string
        scopes: [runs:write, runs:read]
  • CIDR rule. The header is honoured only when the request's direct TCP peer is inside trusted_proxies. A network that covers every address (prefix length 0, such as 0.0.0.0/0) is refused, and an enabled hook needs at least one proxy and one identity.
  • Exact identities. Only a value listed in identities is accepted; any other value is refused. The same header from any other address is refused and audited as untrusted_proxy, even when the request also carries a valid bearer token. Requests without the header fall through to bearer authentication.
  • Header. It may not be a header that already carries identity (authorization, cookie, x-tenant-id, x-forwarded-for and the like). The proxy must overwrite it on every request and never pass a client's value through.
  • Peer address. algen-agent-runtime serve disables uvicorn's X-Forwarded-For rewriting while the hook is enabled; under another server, disable proxy-header rewriting there too. mTLS terminated by the Runtime itself is not mapped to principals.

Authentication audit#

KindNameData
Auditauth.authenticate, outcome failuremethod (jwt, oidc, trusted_proxy), reason, and when known issuer, kid, subject. Under the verified tenant when the token was verified (revoked, missing_jti, revocation_unavailable), otherwise under tenant _authentication.
Auditauth.authenticate, outcome successmethod, issuer, kid, subject; only with record_success: true.
Auditauth.audit.suppressedcount of failure records dropped by the per-minute cap.
Auditauth.token.revoked · auth.subject.revokedexpires_at or revoked_before, reason; the actor is the revoking principal.
Metricalgen_agent_runtime.auth.attemptsCounter with method, outcome, reason.
Logauthentication failed (warning)auth_method, auth_reason, auth_issuer, auth_kid.
agent.yaml · api.authentication_audit (defaults)
api:
  authentication_audit:
    record_failures: true                 # false: no failure records; the metric and log remain
    record_success: false                 # true: one record per successful authentication
    max_failure_records_per_minute: 600   # per process; the excess is counted in auth.audit.suppressed

No record holds the token, its signature or any claim beyond the subject, and a subject is recorded only after the signature is verified (or for a trusted proxy's identity); an unverified token's issuer and kid are truncated to 256 characters. Clients get a generic answer: a rejected token is 401 invalid bearer token with WWW-Authenticate: Bearer error="invalid_token"; unavailable keys or revocation store are 503. The specific reason is only in the audit: missing_credentials, malformed_token, algorithm_not_allowed, unknown_issuer, missing_key_id, unknown_key, keys_unavailable, invalid_signature, expired, not_yet_valid, invalid_issued_at, invalid_audience, invalid_issuer, missing_claim, token_lifetime_exceeded, missing_subject, missing_tenant, tenant_not_allowed, missing_jti, revoked, revocation_unavailable, untrusted_proxy, invalid_service_identity, unknown_service_identity, invalid_token.

Changed for auth_mode: jwt. Authentication failures are now recorded as auth.authenticate audit records (under tenant _authentication, at most 600 per minute per process), and 401 responses carry WWW-Authenticate: Bearer error="invalid_token". Response bodies and the verification itself are unchanged; set api.authentication_audit.record_failures: false to keep the previous audit volume. api.jwt_algorithms may no longer include none. A request is authenticated, and audited, once even when the rate limiter authenticates it before the route.

Known limitations. Token introspection (RFC 7662) is not supported: revocation uses the jti denylist and the per-subject cutoff, and a deployment that needs introspection can run it in a gateway in front of the Runtime. The in-memory revocation store is per process, so a deployment with more than one worker needs token_revocation.store: postgres. See Operations · API authentication and the threat model.

Runtime a16 · readiness and lifecycle

Readiness & lifecycle#

/health/live answers {"status": "ok"} while the process runs. From 0.1.0a16 /health/ready reports every dependency the service has, says why one is degraded, and fails only when a required one is down.

AnswerWhen
200, status: readyEvery dependency is up.
200, status: degradedAn optional dependency is down (an optional MCP server that failed at startup, a cache, a retriever), but every required one is up.
503, status: degradedA required dependency is down or the model-provider rule is not met; failing names them.
503, status: drainingFrom the moment the service starts to shut down; dependencies are not probed then.
GET /health/ready · 200, an optional MCP server is down
{
  "status": "degraded",
  "ready": true,
  "lifecycle": "ready",
  "providers": {"openai": true},
  "stores": {"PostgresDatabase": true},
  "dependencies": {
    "store.run": {"kind": "store", "required": true, "backend": "postgres", "status": "up"},
    "provider.openai": {"kind": "provider", "required": false, "status": "up"},
    "mcp.crm": {"kind": "mcp", "required": false, "status": "down", "reason": "ConnectError"},
    "telemetry.otlp": {"kind": "telemetry", "required": false, "status": "unknown", "reason": "not probeable"}
  },
  "degraded_dependencies": {"mcp.crm": "ConnectError"},
  "failing": []
}

providers and stores keep their earlier meaning. A dependency's status is up, down or unknown (it cannot be probed; it is reported and never fails readiness). Reasons are content-minimal: an exception type, probe failed, probe timed out, not registered, or a message of at most 160 characters with URLs, DSNs and credential-like values removed. Probes run concurrently, each bounded by probe_timeout_seconds.

DependencyNameRequired by default
Configured stores (run, event, approval, audit, artifact, workflow, tool-execution, conversation, memory, learned-memory, analytical-graph; the work queue and MCP token store when used), probed once per PostgreSQL pool, Redis client or S3 bucket; in-memory stores are always upstore.<kind>Yes (require_durable_stores)
Plugin resources with health() or ping()resource.<TypeName>Yes (require_durable_stores)
Model providers (provider.health())provider.<id>See model_providers
Cache on Redis (PING)cache.redisNo
Retrievers (health()/ping(), else unknown)retriever.<name>No
MCP servers (discovered at startup, or down with the startup error)mcp.<server>When required: true and not OAuth
OTLP exporter; Traccia (its health() if it has one)telemetry.otlp · telemetry.tracciaNo (reported unknown)
Other startup failures, for example workflow.recoveryin degraded_dependenciesNo
agent.yaml · runtime.readiness (defaults)
runtime:
  readiness:
    model_providers: any          # any | all | none
    require_durable_stores: true  # every store.* and resource.* dependency
    required: []                  # for example [mcp.crm, retriever.docs]
    optional: []                  # for example [cache.redis]
    probe_timeout_seconds: 2      # 0 < value ≤ 30
SettingDefaultMeaning
model_providersanyany: at least one registered provider must be healthy (failing: ["provider.*"] otherwise); all: every provider is required, the rule before 0.1.0a16; none: providers are only reported. With no providers registered the rule is met.
require_durable_storestrueRequire every store.* and resource.* dependency.
required[]Further dependencies that must be up. A name nothing registers is reported down with not registered.
optional[]Dependencies to report without requiring them. A name cannot be both required and optional.
probe_timeout_seconds2Upper bound for each probe; a slower probe is down.

Names are <kind>.<name> with kind one of store, cache, provider, retriever, mcp, telemetry or resource; algen-agent-runtime check rejects other forms.

Behaviour changes. /health/ready returns 503 only when a required dependency is down. By default one healthy model provider is enough (before 0.1.0a16 every provider had to be), and a Redis cache, retrievers, optional MCP servers and telemetry exporters no longer fail readiness: they make it 200 with status: degraded. Set runtime.readiness.model_providers: all to keep requiring every provider, and list other dependencies under runtime.readiness.required. From the start of shutdown it returns 503 with status: draining.

Start and drain#

created→ starting→ ready→ draining→ stopped

Container.lifecycle.phase is reported as lifecycle. Startup (Container.astart, run by the FastAPI lifespan) starts resources, discovers MCP tools, recovers incomplete agent runs and conversations, recovers the workflow service's interrupted runs, and starts the maintenance loop when maintenance.enabled. Shutdown (Container.aclose) moves to draining, so readiness fails at once, then stops the maintenance loop; drains the workflow service for shutdown_grace_seconds; drains conversations and agent runs with the same grace period; closes retrievers and resources; and moves to stopped. Interrupted agent runs keep a recoverable status and interrupted workflow runs keep their last checkpoint. See Service workflow lifecycle.

Set the orchestrator's termination grace period above shutdown_grace_seconds × 3 plus a margin, so the drain completes before the process is killed. See Operations · readiness and lifecycle.

Operate

Production checklist#

Pre-alpha does not mean prototype, but deployment claims must stay narrow. Before serving external traffic, validate the complete operator boundary.

  1. Threat-model the deploymentDefine trust boundaries for API clients, model providers, tool processes, MCP servers, stores, artifacts, and telemetry exporters.
  2. Externalize durable stateConfigure production stores, migrations, backups, retention, reconciliation, and tenant-isolation verification.
  3. Constrain every egress pathUse secret references, outbound allowlists, TLS verification, safe transports, request limits, and provider-specific timeouts.
  4. Authenticate the serviceReplace development headers with JWT, configure CORS deliberately, and keep the Agent Manifest UI internal.
  5. Prove recoveryExercise worker interruption, approval resume, duplicate delivery, tool indeterminacy, workflow fingerprint mismatch, and store degradation.

Per-run caller credentials. Credentials passed to start, run or resume live only in the process's memory, keyed by run, while it executes; no store, exporter, log line or traceback can hold them. A pause can outlast a token, so supply fresh credentials on every resume, and have tools refuse to act when the mapping is empty. Tools must not echo them in results or errors, which reach the model, events and stores. See Credentials of the caller and the threat model.

Readiness and drain. Point the readiness probe at /health/ready and decide what must be up: list it under runtime.readiness.required, or set model_providers: all. Alert on status: degraded with HTTP 200 as well as on 503. Give the orchestrator a termination grace period above shutdown_grace_seconds × 3, and run one Runtime process per deployment while conversation turns can wait. See Readiness & lifecycle.

Identity. Use auth_mode: oidc (or jwt) outside loopback development; require short-lived tokens with max_token_lifetime_seconds, map tenants explicitly, and turn on token_revocation with the postgres store when tokens must be revocable before they expire. Enable trusted_proxy_identity only behind a proxy that verifies client certificates and overwrites the header, listing only its own CIDRs. See Authentication.

Unknown tool outcomes. Grant tools:reconcile to operators only, watch GET /v1/tool-executions or the tool.reconciliation_required event, give side-effecting tools a reconcile hook where the external system can answer, and use on_indeterminate: pause for agents that should wait rather than fail. See Side-effect operations.

Store outages and upgrades. Run one Runtime process per database or postgres_schema, point postgres_dsn at an endpoint that follows the primary, and have clients retry 503 with Retry-After. Back up the whole database in one snapshot before every upgrade, and run the 0.1.0a18 upgrade (migration 016) in a quiet period. See Store outages.

Policies. Ship company-wide rules as signed bundles with require_signed: true and min_version, keep the signing key in your release pipeline, and alert on any policy.error record with outcome fail_open. See Policy bundles & fail modes.

Egress, processes and plugins. Send outbound traffic through network.egress_proxy with its own egress policy, pin or mTLS-authenticate partner APIs under network.remotes, set security.sandbox.required: true with a wrapper wherever stdio MCP servers or core.subprocess run, and set plugins.require_signed: true with read-only plugin packages. See Egress, sandbox & signing.

Bounds. Lower max_model_calls, max_response_chars and max_events for agents that face untrusted input, set storage.quotas per tenant, turn on the retention windows, and grant approvals:read and usage:read on purpose. See Limits, quotas & retention.

Read Production readiness for current guarantees and gaps, and Operations for health, migrations, and runbooks.


Reference

CLI#

The package installs one entry point, algen-agent-runtime. With no subcommand it runs serve.

CommandPurposeKey flags
serveRun the HTTP service.--config · --host · --port · --ui · --banner/--no-banner
new <name>Scaffold an offline-runnable project.--template agent|approval|workflow · --dir · --force
describeList every component the settings register and its source, without running any.-c/--config · --path · --json
check [agent.yaml]Validate a project as Studio will and warn about code Studio cannot run.--path · --project-root · --json · --strict · --profile studio|standalone
run AGENT INPUT --attach FILERun an agent in process with a file attached: it is stored, scanned with the basic scanner and attached (repeatable).
policy keygen · sign · verifyCreate an Ed25519 key pair, sign a policy bundle, verify it as start-up does. See Signed policy bundles.--private-key · --public-key · --key · --trusted-key · --min-version
plugin keygen · sign · verifyCreate a signing key, sign a plugin package, verify a plugin without importing it. See Plugin signing.--publisher · --key · --name · --version · --config
ui · export-uiExport the Agent Manifest UI as static HTML.-c/--config · -o/--output · --title · --open
Reference

API map#

The default composition root is intentionally singular. Public contracts stay provider-neutral; adapters live at the edges. Public APIs are experimental during 0.x — see API stability.

PackageOwnershipStatus
typesNormalized public contracts: RunRequest, RunResult, AgentDefinition, RunStatus.Core
runtimeLegal state transitions, termination, and AlgenAgentRuntimeClient (run, run_sync, stream).Core
policies · toolsGovernance strategies and side-effect control.Core
workflowsTyped multi-agent DAG execution.Core
orchestrationDefault settings-driven composition root: build_container.Core
models · persistenceProvider and infrastructure adapters.Adapters
mcpDeclarative MCP client, transports, and tool mapping.Adapters
frameworksTrusted outer adapters for external frameworks. Registration is explicit; Runtime never auto-dispatches agents into them.Optional
uiAgent manifest spec extraction and static UI generation.Optional
Reference

Troubleshooting#

Configuration fails to load with an unknown or extra field.

Settings are strict: unknown keys fail fast instead of being ignored. Check spelling against the schema, and make sure environment variables use the ALGEN_AGENT_RUNTIME__ prefix with double-underscore nesting.

An agent's model references an unknown provider.

default_model.provider, fallback models, and embedding providers must name a configured provider instance key (for example local), not the adapter type.

max_concurrent_runs_per_tenant is rejected.

The alias was removed in 0.1.0a4. Rename it to max_concurrent_run_requests_per_tenant, which bounds in-flight POST /v1/runs concurrency.

A manifest is rejected for an unresolved {{…}} placeholder.

Scaffolded templates contain placeholders that must be replaced before loading. Search the project for {{ and fill or remove each one.

An import error says an extra is missing.

Your configuration enables an optional integration. Install the named extra, for example pip install --pre 'algen-agent-runtime[mcp]' or [postgres,redis].

serve refuses to bind to 0.0.0.0.

Development header authentication may bind only to loopback. Switch to api.auth_mode: jwt for shared networks. Setting allow_insecure_development_auth: true is only for an explicitly trusted development network.

HTTP calls return 401, 403, or 429.

401: tenant and user identity are missing. 403: the caller lacks a scope such as runs:write. 429: a tenant rate limit was hit — honor Retry-After.

/agent-ui returns 404.

The route is disabled by default. Set api.agent_ui_enabled: true or start with serve --ui.

An MCP server header is rejected as a literal secret.

Sensitive headers and tokens must be env:// references via auth_token_ref or secret_headers.

A tool call is answered with "ok": false and “Invalid arguments”.

From 0.1.0a14 arguments are checked against the tool's input_schema before the call runs or any approval is created. The error names where the arguments do not fit; fix the tool's schema or the agent's instructions. A fail_run agent fails on it instead.

An MCP tool returns MCPNotConnected.

The server uses OAuth and the caller (or, for token_scope: tenant, the tenant) has no usable connection. Start one with POST /v1/mcp/servers/{server}/oauth/start, the start_path in the mcp.auth_required event. With the in-memory store, a restart forgets every connection.

A run failed with reason: budget_refused.

The deployment's usage meter raised MeterRefusedError; its message is on RunState.error and the budget.refused event. It is not a provider failure and is not retried.

A run failed with reason: budget_refused.

The deployment's usage meter raised MeterRefusedError; its message is on RunState.error and the budget.refused event. The event and its audit record name the provider and model whose reservation was refused, so you can tell which account or model ran out. It is not a provider failure and is not retried.

A DeprecationWarning says the usage meter's reserve has no call parameter.

The meter uses the 0.1.0a14 form. It still works and is charged as before, but the form is removed in 0.2: add call: MeterCall to reserve. See Usage metering.

A tool says “authentication required” after an approval.

Caller credentials are kept in memory only and dropped when the run pauses. Pass them again with runtime.resume(..., credentials=...); a run recovered after a restart, or started from a workflow or the HTTP API, has none. See Credentials of the caller.

FastAPI request spans no longer appear in Traccia.

From 0.1.0a14 Traccia receives only traces the Runtime started. Set telemetry.traccia.export_foreign_traces: true to send everything as before.

A new conversation message gets 409.

From 0.1.0a16 a turn whose run waits for an approval or an answer stays awaiting_approval or awaiting_input, and only one turn per conversation can wait. The 409 names the waiting turn id: answer it with POST /v1/conversations/{id}/turns/{turn_id}/approval or /input (or ConversationService.respond), or cancel it with DELETE /v1/conversations/{id}/active-response, which cancels its run too. See Conversation turns.

/health/ready says degraded, or returns 503.

degraded with HTTP 200 means an optional dependency is down (a cache, a retriever, an optional MCP server, or workflow.recovery): the service still takes traffic, and degraded_dependencies says why. 503 with degraded means a required dependency is down or no model provider is healthy: failing names it. 503 with draining means the service is shutting down. Adjust what is required with runtime.readiness. See Readiness & lifecycle.

A workflow run was not recovered after a restart.

The service recovers only runs it owns (owner: workflow_service). Runs started through the HTTP API before 0.1.0a16, and runs an application started with its own executor, have no owner: decide or answer them through the API, or recover them once with executor.recover. An owned run is also left untouched, and logged, when its manifest changed or is no longer configured, or its hooks are not trusted; and a waiting run is not resumed by recovery. See Service workflow lifecycle.

Calls get 401 with WWW-Authenticate: Bearer error="invalid_token".

The bearer token was refused. From 0.1.0a17 the answer is the same for every reason; the reason is in the auth.authenticate audit record (tenant _authentication unless the token was verified), the authentication failed log and the algen_agent_runtime.auth.attempts metric. Common ones: expired or not_yet_valid (clock skew beyond leeway_seconds), invalid_audience, unknown_issuer (the iss does not exactly match a configured issuer), unknown_key (a kid not yet in the JWKS; refreshes are rate-limited), missing_tenant (no tenant claim and no fixed_tenant), token_lifetime_exceeded, and revoked. See Authentication.

Calls get 503 when the identity provider or revocation store is down.

Authentication fails closed. keys_unavailable: the Runtime has never fetched the issuer's JWKS (once fetched, the last good keys stay in use while refreshes fail); check discovery, the jwks_url host and egress (additional_allowed_hosts, allow_private_networks for an in-cluster provider, https). revocation_unavailable: token_revocation is on and its store cannot be read; restore the database, or turn the check off knowingly. See OIDC issuers and JWKS and Token revocation.

A run stays in awaiting_reconciliation.

The agent has tool_policy.on_indeterminate: pause and a side-effecting call's outcome is unknown. The run waits, without expiring, until someone with tools:reconcile settles the call: find it with GET /v1/tool-executions?run_id=…, then reconcile, resolve or compensate it. If the call is already settled but the run still waits (the Runtime stopped in between), POST /v1/tool-executions/{key}/resume. Cancel the run to give up. See the runbook.

Resolving a tool call returns 409.

The call is not in a state that decision can change. Read it again with GET /v1/tool-executions/{key}: someone else may have decided it first (each decision is a compare-and-set), it may no longer match your expected_status, its outcome may already be known, or it is started and changed less than 10 minutes ago, so it may still be running. reconcile on a tool without a hook, and compensate on one without a compensation or on a call that is not completed, indeterminate or compensation_failed, are 409 too. See Rules.

A schema-constrained step on Claude 5.x fails with HTTP 400 "does not support assistant message prefill".

Before 0.1.0a21 the repair after a failed output check ended the conversation with the model's own answer, which Claude 5.x rejects. Upgrade to 0.1.0a21: the repair is a user message, and schema limits the provider drops are described in the schema it sends.

A long agentic run fails with JSONDecodeError from a provider adapter.

The model wrote a tool call whose arguments are not valid JSON. From 0.1.0a21 that is a retryable InvalidToolCallError and the run asks the model again; look for model.tool_call_invalid audit events to see how often a model does it.

A workflow with a tool: step will not start, or a direct executor call is refused for approval.

From 0.1.0a19 a tool with requires_approval runs only under an approval. Put an approval node upstream of the tool: step, or pass approval= when calling ToolExecutor.execute yourself. See Human approvals.

A run with attachments is refused before any model call.

The agent did not opt in (attachments.enabled), a file is not available yet (uploads stay pending_scan until scanned; set security.upload_scanner: basic), its real type is not allowed, a limit is exceeded, or no configured model reports images (or files for PDF). The error names which. See File attachments.

The service started but a toolkit's tools fail with a configuration error.

From 0.1.0a19 a toolkit that cannot start degrades readiness instead of stopping the service. From 0.1.0a20 its calls raise ToolkitConfigurationError with what to set or install. Fix the setting, or build the toolkit with required=True to fail at start.

Calls get 503 with Retry-After while the database is down.

From 0.1.0a18 a PostgreSQL or Redis store that cannot be reached, times out or is shutting down raises StoreUnavailableError, answered with 503 instead of 500. Retry after the header's delay. Runs in flight keep their checkpoint and continue when the store is back. If calls time out on a slow but healthy database, raise storage.postgres_command_timeout_seconds (60 by default) or redis_socket_timeout_seconds (10). See Store outages.

A write gets 413 with code: QUOTA_EXCEEDED.

The tenant reached a storage.quotas limit; resource and dimension say which. Retrying cannot succeed: delete data or raise the quota. GET /v1/storage/usage shows usage and limits. A full session-memory quota does not fail a run; it skips the turn's memory. See Storage quotas.

A run or tool call is refused with reason_code: policy.error.

A policy raised or ran past policies.evaluation_timeout_seconds, and the boundary is closed (the default). The policy.error audit record and the policy_error log name the boundary, policy and error type. Fix the policy; set policies.boundary_on_error to open for that boundary only if letting values through without it is acceptable. See Boundaries and fail modes.

A tool call is refused because its arguments carry a token or card number.

From 0.1.0a18 the secrets policy redacts bearer tokens and the pii policy card numbers, and a redacted tool argument refuses the call at before_tool. Pass credentials to tools as env:// references or caller credentials (auth.type: caller for HTTP tools), never as argument values. UUIDs, hashes and other identifiers are no longer mistaken for phone or Aadhaar numbers.

A run failed with reason: limit_exceeded.

The run reached a per-run limit; limit on the run.failed event names it (model_calls, response_chars, events, or a step, token or cost limit). Raise it on the agent's budget only if the run is legitimate; a run that keeps reaching one is usually looping. See Run limits.

Start-up refuses a policy bundle or plugin.

A bundle's signature does not verify against trusted_keys, it is unsigned with require_signed: true, expired, issued in the future, or below min_version; check it with algen-agent-runtime policy verify. A plugin is unsigned or not from a trusted_publishers entry under plugins.require_signed, or a file changed after signing; check it with algen-agent-runtime plugin verify. See Egress, sandbox & signing.

Start-up fails because postgres_schema is not in effect.

The connection's current_schema() is not the configured schema: a pooler in transaction mode dropped the search_path startup parameter, or the role lacks USAGE on the schema. Use session pooling (or add search_path to PgBouncer's track_extra_parameters), grant USAGE and CREATE, or create the schema (or set initialize_schema: true). See Sharing a database.

Resuming a workflow fails with a conflict.

Either the workflow is not paused in the state you expected, or you are resuming with a different manifest name or version than the one that started it. Resume with the exact original manifest.

Reference

Glossary#

Agent definition
A versioned agent: default model, instructions, tools, policies, and budget.
Approval node
A workflow node that pauses for an attributed human decision.
Audit event
An immutable evidence record of actor, action, outcome, and resource.
Capability narrowing
Effective capabilities are the intersection of adapter-reported and configured capabilities; widening fails safe.
Checkpoint
Persisted run or workflow state that makes execution recoverable.
Composition root
build_container: the one place where settings become wired runtime components.
env:// reference
A pointer to a secret in the environment, such as env://OPENAI_API_KEY, used instead of a literal.
Hook provider
Trusted Python that supplies named builders, handlers, and schemas to a workflow.
Idempotency
How safely a tool call can be repeated: idempotent, keyed, or non-idempotent.
Provider instance
A named deployment of a model adapter type, with its own health, limits, and cost accounting.
Run event
One typed, sequenced entry in a run's lifecycle stream.
Side effect
A tool's classification — none, read, write, external, or destructive.
Tenant
The isolation boundary carried on every request, store record, and event.
Workflow manifest
A versioned, fingerprinted DAG of nodes declared in YAML.
Reference

Deep dives#

Continue into focused design and operational guides from the repository documentation.

No sections match your search. Try a different term.