Liman

Proof of Concept

Liman framework design

Overview

Liman is a powerful framework engineered to build scalable, reliable, and maintainable AI Agents. It achieves this through a declarative manifest approach and an extensible modular architecture.

The core idea behind Liman is to define agent structures using manifests that incorporate basic, reusable components. These components are easily pluggable and cover key areas such as:

  • Declarative Approach: Emphasizing a declarative approach through YAML manifests, similar to Kubernetes, augmented with an overlay (kustomize-like) feature for flexible configuration and extension.
  • Telemetry: For comprehensive monitoring, insights, and cost optimization (FinOps), leveraging open standards like OpenTelemetry (Otel) for distributed tracing and metrics.
  • Authorization: For secure access control, supporting robust mechanisms such as service accounts, assuming roles, and fine-grained access control to manage permissions effectively.
  • Protocols: To facilitate diverse communication needs and seamless integration with various systems, including MCP, HTTP, WebSocket, A2A and others
  • Side Effects: For managing external interactions throughout the agent's lifecycle, enabling operations such as external API calls, database interactions, and other service integrations in a controlled and defined manner.
  • Extensible State: For highly flexible data management, allowing agents to utilize any datastore and any data format required, ensuring adaptability to diverse operational needs.

Should this specification be widely adopted, it could foster a common ecosystem of reusable components for AI Agents, paving the way for an "Agents Store" where shared functionalities can be discovered and integrated across diverse projects.

YAML Manifests

Liman represents AI Agents as a Graph of Nodes, similar to frameworks like LangGraph. In this graph:

  • Each Node acts as a computational unit with its own configuration and behavior.
  • Edges define the flow of data between these nodes.

This entire structure is defined in a YAML manifest file (JSON is also supported), which explicitly describes nodes, their properties, and their interconnections. The manifest uses a declarative approach, much like Kubernetes manifests, and supports an overlay design (similar to Kustomize) for extending base manifests with additional configurations.

This declarative methodology enables the creation of language-agnostic AI Agents. The agent's logic is defined solely in the manifest, allowing its underlying implementation to be in any programming language. Think of it like OpenAPI specifications with custom code generation: the manifest can be used to generate code in various languages such as Python, JavaScript, Go, and others.

Liman currently supports three primary Node types: LLMNode, ToolNode, and Node.

LLMNode

An LLMNode encapsulates the logic for interacting with Large Language Models (LLMs). Its primary function is to define prompts, often for multiple languages.

kind: LLMNode
name: StartNode
prompts: # <- LanguageBundle
  system:
    en: |
      You are a helpful assistant.
    ru: |
      Ты полезный помощник.
  notes:
    en: { notes }
tools:
  - OpenAPI_getUser

A core feature of Liman is the LanguageBundle, which is used to parse every text section within the manifests. LanguageBundle supports a fallback language. If text is not defined for a specific language, the system automatically uses the designated fallback language.

For example, in the LLMNode snippet above, if notes were not defined for ru, the en version would be used as a fallback. This mechanism applies to any text section, regardless of its depth.

Here's how LanguageBundle handles fallback language:

prompts:
  system:
    en: You are a helpful assistant.
  notes:
    intro:
      en: Hello
      ru: Привет
    bye:
      de: Auf Wiedersehen

If en is set as the fallback language, the system would process the above into:

prompts:
  system:
    en: You are a helpful assistant.
    ru: You are a helpful assistant.
    de: You are a helpful assistant.
  notes:
    en:
      intro: Hello
    ru:
      intro: Привет
    de:
      intro: Hello # Fallback applied for 'intro'
      bye: Auf Wiedersehen

Overlays

Overlays extend base manifests with additional configurations. They allow you to define extra properties for a node, such as tools, prompts, and more. A common use case is defining different prompts for various languages.

Overlays are applied in a specific order, based on their directory level and file name. Consider the following structure:

specs/
└── start_node/
    ├── llm_node.yaml
    └── langs/
            ├── en.yaml
            ├── ru.yaml
            └── de.yaml

Given these manifest files:

# start_llm_node.yaml
kind: LLMNode
name: StartNode
tools:
  - OpenAPI_getUser

# langs/en.yaml
kind: Overlay
to: LLMNode:StartNode
prompts:
  system: |
    You are a helpful assistant.
  notes:
    intro: Hello
    bye: Goodbye

# langs/ru.yaml
kind: Overlay
to: LLMNode:StartNode
prompts:
  system: |
    Ты полезный помощник.
  notes:
    intro: Привет
    bye: Пока

# langs/de.yaml
kind: Overlay
to: LLMNode:StartNode
prompts:
  system: |
    Du bist ein hilfreicher Assistent.
  notes:
    intro: Hallo
    bye: Auf Wiedersehen

These separate files would be merged into a single LLMNode manifest. Overlays are versatile and can be applied to any node type, not just LLMNode, to extend its properties.

ToolNode

A ToolNode defines the logic for LLM function calling. It specifies a tool's signature and associated prompts.

kind: ToolNode
name: GetUser
description: # <- LanguageBundle
  en: Get user by ID
  ru: Получить пользователя по ID
  de: Benutzer nach ID abrufen
func: lib.tools.get_user # <- Function to call, would be imported during the parsing
arguments:
  - name: user_id
    type: string
    description: # <- LanguageBundle
      en: User ID to get
      ru: ID пользователя для получения
      de: Benutzer-ID zum Abrufen
triggers: # <- LanguageBundle, optional
  en:
    - Give me the user X
    - What is the user X?
  ru:
    - Дай мне пользователя X
    - Какой пользователь X?
  de:
    - Gib mir den Benutzer X
    - Was ist der Benutzer X?
tool_prompt_template: # <- LanguageBundle, optional
  en: |
    Supported tools:
      {name} - {description}
      Examples that can trigger this tool:
        {triggers}
  ru: |
    Поддерживаемые функции:
      {name} - {description}
      Примеры, которые могут вызвать эту функцию:
        {triggers}
  de: |
    Unterstützte Werkzeuge:
      {name} - {description}
      Beispiele, die dieses Tool auslösen können:
        {triggers}

During parsing, this ToolNode allows for easy generation of the tool calling signature for the LLM, which is then provided to the func.

The triggers and tool_prompt_template fields are optional. However, they are highly useful for providing extra information about the tool and how it can be invoked.

When you link a ToolNode to an LLMNode, as shown below:

kind: LLMNode
name: StartNode
prompts:
  system:
    en: You are a helpful assistant.
tools:
  - GetUser

By default, the LLMNode.prompts.system will automatically incorporate the tool_prompt_template for each tool to improve function calling accuracy

So, the system prompt for the English (en) language would be augmented as follows:

You are a helpful assistant.
Supported tools: 
  GetUser - User ID to get  
  Examples that can trigger this tool:  
    - Give me the user X
    - What is the user X?

This automatic integration also applies to other languages, ensuring consistent prompt enhancement across all supported locales.

OpenAPI → ToolNode Generation

One of Liman's powerful features is the ability to automatically generate ToolNode definitions from OpenAPI specifications. This means your existing REST APIs can become AI agent tools without writing custom integration code.

When developing chat systems, many organizations already have REST APIs that could be useful as tools. OpenAPI specifications contain all the needed information to generate a ToolNode and make it available to the agent.

Automatic Conversion

Here's how a simple OpenAPI endpoint gets converted. Given this OpenAPI specification:

paths:
  /users/{userId}:
    get:
      operationId: getUserById
      summary: Get user by ID
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
          description: The user's unique identifier
      responses:
        "200":
          description: User information

It automatically becomes this ToolNode:

kind: ToolNode
name: OpenAPI__GetUserById
description:
  en: Get user by ID
func: liman_openapi.gen.id_4341576960.getUserById # Auto-generated function by SDK
arguments:
  - name: userId
    type: str
    required: true
    description:
      en: The user's unique identifier

Extending Generated Tools

The generated ToolNode can be extended using overlays to add additional functionality:

kind: Overlay
to: ToolNode:OpenAPI__GetUserById
strategy: merge
nodes:
  - target: OpenAPIErrorHandler
    when: status_code != 200

This approach eliminates the need to manually create MCP servers or write custom tool definitions, significantly accelerating the development of AI agents that integrate with existing APIs.

Condition Expression Language (DSL CE)

One of Liman's most powerful features is DSL CE (Domain Specific Language Condition Expression) - a custom language for defining intelligent flow control between nodes. Rather than writing repetitive conditional logic in code, you can express complex routing decisions declaratively in YAML.

Declarative Flow Control

CE DSL allows you to define when transitions between nodes should occur using expressive conditional statements:

nodes:
  # Simple condition
  - target: SuccessHandler
    when: status == 'complete'

  # Complex logical expressions
  - target: RetryHandler
    when: failed and (retry_count < 3 or priority == 'high')

  # Function references for custom logic
  - target: CustomValidator
    when: business_rules.validate_transaction

  # Built-in functions
  - target: ErrorHandler
    when: $is_error('UnauthorizedError') or $is_error('TimeoutError')

  # Context-aware routing
  - target: HighPriorityPath
    when: user.tier == 'enterprise' and (urgent == true or customer_complaint == true)

Supported Operators and Functions

CE DSL provides a rich set of operators and built-in functions:

Comparison Operators

  • ==, !=: Equality and inequality
  • >, <, >=, <=: Numerical comparisons
  • String and type-aware comparisons

Logical Operators

  • and / &&: Logical AND
  • or / ||: Logical OR
  • not / !: Logical NOT
  • Parentheses for grouping: (condition1 and condition2) or condition3

Built-in Functions

  • $is_error(error_type): Check for specific error types
  • $now(): Current timestamp for time-based conditions

Function References

CE DSL can call external functions for complex business logic:

nodes:
  - target: ApprovalRequired
    when: business_rules.requires_approval
  - target: AutoProcess
    when: validation.is_safe_transaction

Context Variables

CE DSL has access to rich context information:

  • Node Results: Access outputs from previous nodes
  • User Data: User profile, permissions, session information
  • System State: Current system status, configuration values
  • Request Context: Incoming request data, headers, metadata

Expression Evaluation

CE DSL expressions are evaluated at runtime with full access to the current execution context. This allows for dynamic routing based on:

  • Data-driven decisions: Route based on content analysis
  • User-specific flows: Different paths for different user types
  • Error handling: Sophisticated error recovery strategies

This approach transforms static agent definitions into dynamic, intelligent systems that can adapt their behavior based on runtime conditions and context.

Execution model

Overview

The execution model consists of three main components:

  • NodeActor manages role assumption, state, and node wrapping
  • Launcher handles execution context creation, resource management, and NodeActor lifecycle
  • Executor focuses purely on graph traversal, CE DSL evaluation, and flow control
Execution Model
Executor -> Launcher -> NodeActor -> Node

Developer can work with Liman at any level: Node, NodeActor, Launcher, or Executor, depends on it's needs and required customization.

NodeActor

In Liman, every node is executed by its own dedicated NodeActor with limited scope and authorization. This isolation ensures security, resource control, and fault tolerance across distributed agent systems. Each node runs within its own execution context, similar to how containers isolate processes in containerized environments. The NodeActor is an internal component that wraps each node's execution.

Authorization Scoping

Each NodeActor operates with minimal required permissions defined at the node level. The authorization system is built around role assumption and credential provisioning to ensure secure access to external resources.

ActorNode Role Assumption

When a NodeActor needs to execute a node, it can assume specific roles required for that operation. This follows the principle of least privilege - each node only gets the permissions it absolutely needs.

For example, a user lookup tool would only assume a "user-data-reader" role with read permissions for user data, while a ticket creation tool would assume a "ticket-creator" role with write permissions only for the ticketing system.

CredentialsProvider Integration

The CredentialsProvider is responsible for supplying the necessary credentials when a NodeActor assumes a role. This abstraction allows for flexible credential management across different environments and services.

For OpenAPI calls, the CredentialsProvider automatically provides the appropriate authentication headers based on the assumed role and target service.
For example, when a ToolNode makes an OpenAPI call to retrieve user data, the NodeActor assumes the "user-data-reader" role, and the CredentialsProvider automatically injects the required Bearer token into the HTTP request headers.

Dynamic Credential Resolution

The CredentialsProvider resolves credentials dynamically based on the assumed role and target service:

  • API calls: Provides Bearer tokens, API keys, or OAuth credentials
  • Cloud services: Delivers service account tokens or IAM role credentials

This approach ensures that sensitive credentials are never hardcoded in manifests and are only provided to NodeActors that have explicitly assumed the necessary roles.

Launcher

The Launcher serves as the execution context manager, responsible for creating and managing NodeActors in different execution environments. It provides a clean abstraction layer between graph orchestration (handled by the Executor) and actual node execution, enabling flexible deployment across various compute contexts.

Architecture Design

The Launcher pattern follows the Executor + Launcher architecture:

Launcher Types

Liman supports multiple launcher implementations, each optimized for different execution contexts:

AsyncLauncher: Designed for high-concurrency I/O-bound operations using async/await patterns. Ideal for API calls, database queries, and LLM inference with low memory overhead and shared memory space for fast data exchange.

ThreadLauncher: Optimized for I/O-bound operations requiring thread-level isolation while maintaining shared memory access.

ProcessLauncher: Built for CPU-intensive tasks requiring complete process isolation and parallel processing capabilities. Essential for machine learning model inference, data processing, computational algorithms, and security-sensitive operations requiring strict isolation.

DistributedLauncher: Future implementation for distributed execution across multiple machines, containers, or cloud functions, enabling unlimited horizontal scalability.

Dynamic Selection and Resource Management

Launchers provide intelligent selection based on node characteristics and runtime conditions. The system can route different node types to appropriate execution contexts - LLM nodes to async launchers for I/O efficiency, CPU-intensive nodes to process launchers for parallel execution, and secure operations to isolated process launchers.

Implementation Benefits

The Launcher abstraction allows the Executor to remain focused on graph orchestration while delegating execution concerns to appropriate launcher implementations. This clean separation enables flexible deployment strategies, from simple single-threaded execution to complex distributed systems, all while maintaining consistent behavior and comprehensive observability.

Executor

The Executor is the orchestration engine that processes agent workflows through pure graph orchestration. It focuses exclusively on graph traversal, CE DSL evaluation, and flow control while delegating actual node execution to Launchers. This clean separation allows the Executor to remain focused on workflow logic while Launchers handle execution context management.

Execution Modes

The Executor supports flexible execution approaches:

  • Full Graph Traversal: Process entire agent graphs following CE DSL edge conditions
  • Subgraph Execution: Start execution from any node for workflow resumption

Parallel Execution & Synchronization

The Executor supports sophisticated parallel execution patterns:

  • Fan-out: Automatically launch multiple nodes in parallel when CE DSL conditions allow concurrent execution
  • Dependency Management: Track node dependencies and ensure execution order compliance
  • Sink Operations: Wait for all parallel branches to complete before proceeding to dependent nodes
  • Selective Synchronization: Continue execution as soon as required dependencies finish, without waiting for all parallel branches

This enables complex execution patterns where independent operations run concurrently while dependent operations wait for their prerequisites to complete.

Smart Edge Traversal

The Executor uses CE DSL to intelligently determine execution paths, evaluating conditions at runtime based on node results, context, and system state.

Built-in Resilience

  • Fault Isolation: Node failures don't crash the entire graph
  • Retry Policies: Configurable retry logic for transient failures
  • Circuit Breakers: Prevent cascading failures in distributed scenarios
  • Graceful Degradation: Continue execution when non-critical nodes fail

This separation of concerns between Executor (graph orchestration) and Launcher (execution context) allows Liman agents to scale seamlessly from simple applications to complex distributed systems while maintaining clean architecture, consistent behavior, and comprehensive observability.

Authientication & Authorization

Liman provides a robust authentication and authorization system built around the ServiceAccount specification. This system ensures secure access control and follows the principle of least privilege, where each node only gets the permissions it absolutely needs.

ServiceAccount

A ServiceAccount defines the authentication and authorization context for node execution. It provides credentials and context variables that nodes need to access external services and resources.

kind: ServiceAccount
name: MyServiceAccount
context:
  strict: true
  inject:
    - user_id: user.id
    - organization.id
credentials_provider: GCPCredentials

Key Components

Context Variables

The context field allows you to inject specific variables from the external state into the service account. This enables fine-grained control over what data is available to each node.

Context Configuration

  • strict: If true, throws an error if a required variable is not found in the state (default: true)
  • inject: List of variables to inject into the service account context

Variable Injection Formats

  • Direct notation: organization.id - accessed as context.organization.id
  • Custom assignment: user_id: user.id - accessed as context.user_id

Example context configuration:

context:
  strict: true
  inject:
    - user_id: user.id # Custom name assignment
    - organization.id # Direct path
    - project.settings.region

Credentials Provider

The credentials_provider (or credentials_providers for multiple providers) specifies how authentication credentials are obtained. These are phantom specifications - not declared in YAML but provided to the registry during runtime.

Single Provider

credentials_provider: GCPCredentials

Multiple Providers

credentials_providers:
  - GCPCredentials
  - AWSS3Credentials

Node Authz

Service accounts can be attached to nodes through the auth field, enabled by the built-in AuthPlugin. NodeActor execution is scoped to the service account, ensuring that nodes only have access to the permissions defined in their associated service account.

kind: LLMNode
name: SecureLLMNode
prompts:
  system: You are a helpful assistant.
auth:
  service_account: MyServiceAccount

Inlined ServiceAccounts

ServiceAccounts can be declared inline within node specifications, eliminating the need for separate ServiceAccount manifests:

kind: ToolNode
name: UserLookupTool
description:
  en: Look up user information
auth:
  # Inlined ServiceAccount
  service_account:
    context:
      inject: [user_id]
    credentials_provider: UserDataCredentials

Runtime Integration

NodeActor acts as a selected ServiceAccount (based on DSL CE) and executes a node, what ensures that:

  1. State Isolation - context variables are extracted from external state in a controlled manner, ensuring only necessary data is exposed to nodes.
  2. Credential Provisioning - the CredentialsProvider supplies necessary credentials
    • API calls: Bearer tokens, API keys, OAuth credentials
    • Cloud services: Service account tokens, IAM role credentials
  3. Minimal Permissions - each ServiceAccount provides only the minimum required permissions for its associated node, following the principle of least privilege.

Plugins System

Liman's Plugin System enables dynamic extension of node specifications without hardcoding functionality. This allows users to add custom logic (such as authentication, monitoring, or custom behaviors) to any node type through a clean, declarative API. It helps maintain simple declarations while allowing extension to complex use cases.

Core Architecture

The plugin system is built around three key components:

  1. Plugin Registry: Central registration point for all plugins
  2. Plugin Protocol: Standard interface all plugins must implement
  3. Dynamic Schema Enhancement: Runtime modification of node specifications

Built-in Plugins

  • AuthPlugin - Liman includes a built-in authentication plugin that adds auth fields to all node types
    This enables authentication configuration in YAML manifests:
    kind: Node
    name: SecureNode
    prompts:
      system:
        en: You are a helpful assistant.
    auth:
      service_account:
        context:
          inject: [user_id, org_id]
        credentials_provider: GCPCredentials

Python

Plugin Interface

Every plugin must implement the following interface:

from typing import Any, Protocol
from abc import abstractmethod

class Plugin(Protocol):
    @property
    @abstractmethod
    def name(self) -> str:
        """Unique plugin identifier"""
        ...

    @property
    @abstractmethod
    def applies_to(self) -> list[str]:
        """List of spec types this plugin extends (e.g., ['Node', 'LLMNode'])"""
        ...

    @property
    @abstractmethod
    def field_name(self) -> str:
        """Field name added to specifications"""
        ...

    @property
    @abstractmethod
    def field_type(self) -> type:
        """Field structure type (e.g., Pydantic model in python)"""
        ...

    @abstractmethod
    def validate(self, spec_data: Any) -> Any:
        """Validate and transform plugin-specific data"""
        ...

Plugin Registration

Plugins are registered before node specifications are imported, enabling runtime schema enhancement

register_plugins([
    AuthPlugin(),
    MCPPlugin(),
    A2APlugin(),
])

Example

In this example, we create a custom plugin for adding metrics to nodes. This plugin can be applied to specific node types like LLMNode or ToolNode, allowing users to define metrics configurations directly in their YAML manifests.

myproject/plugins.py
from typing import Any
from pydantic import BaseModel
from liman_core.plugins.registry import Plugin

class MetricsSpec(BaseModel):
    enabled: bool = True
    custom_tags: dict[str, str] = {}
    alert_thresholds: dict[str, float] = {}

class CustomMetricsPlugin:
    name = "custom_metrics"
    applies_to = ["LLMNode", "ToolNode"]  # Only specific node types
    field_name = "metrics"
    field_type = MetricsSpec

    def validate(self, spec_data: Any) -> MetricsSpec:
        return MetricsSpec(**spec_data) if spec_data else MetricsSpec()

Usage in YAML:

kind: LLMNode
name: StartNode
prompts:
  system: You are a helpful assistant.
metrics:
  enabled: true
  custom_tags:
    team: ai-platform
    environment: production
  alert_thresholds:
    response_time: 2.0
    error_rate: 0.05

Plugin Composition

Multiple plugins can extend the same node type, creating rich, composable specifications:

kind: ToolNode
name: EnhancedTool
description:
  en: Enhanced tool with multiple plugin extensions

# builtin AuthPlugin
auth:
  service_account: ToolServiceAccount

# CustomMetricsPlugin
metrics:
  enabled: true
  custom_tags:
    priority: high

# RateLimiterPlugin
rate_limiter:
  requests_per_minute: 60
  burst_limit: 10

Benefits

  1. Extensibility: Add any custom functionality without modifying core specifications
  2. Composability: Multiple plugins can enhance the same node type
  3. Clean Separation: Core specs remain focused, plugins handle specialized concerns

This plugin architecture enables the Liman ecosystem to grow organically, with community-contributed plugins for authentication, monitoring, security, compliance, and specialized domain logic.


To Be Continued...

This specification is actively being developed. More sections covering advanced features, implementation details, and practical examples will be added soon.

Stay tuned for updates on distributed state management, observability patterns, and real-world deployment scenarios.

Last updated on