Skip to main content

perm-agent

Declarative, JSON-native AI agent framework. Define workflows as data, not code.

pip install perm-agent

Why perm-agent

Every agent framework asks you to write Python orchestration code. perm-agent takes a different approach: workflows are JSON specifications that can be versioned, stored, shared, and composed -- like Terraform for AI pipelines.

But you don't have to start with JSON. The Python API is simple and compiles down to JSON specs automatically.

Key idea: simple Python for simple cases, JSON specs for complex/dynamic workflows.

from perm_agent import Agent

agent = Agent(
    name="researcher",
    model="openai/gpt-4o",
    instructions="You are a research assistant. Answer concisely.",
    tools=[search],
)

result = agent.run("What causes aurora borealis?")

Under the hood, this compiles to a JSON spec:

[
  {
    "op": "agent_loop",
    "model": "openai/gpt-4o",
    "instructions": "You are a research assistant. Answer concisely.",
    "input": "What causes aurora borealis?",
    "tools": ["search"],
    "max_iterations": 10,
    "path": "/result"
  }
]

That JSON spec can be saved to a database, loaded later, modified programmatically, or sent over HTTP.


Features

Feature Description
Python-first API Agent, Pipeline, @agent decorator
JSON-native workflows Serializable, versionable, composable specs
Model-agnostic Any LLM via litellm (OpenAI, Anthropic, Gemini, local models)
Multi-agent Handoffs, pipelines, agent composition
Async Full async support for all handlers
Streaming Token-by-token streaming with event system
Observability Built-in tracing with spans, events, cost tracking
Structured output Pydantic model validation for LLM responses
Guardrails Input/output content filtering and validation
Retry Configurable retry with exponential backoff
Type-safe tools Auto schema from type hints, Pydantic models, enums, docstrings
Error recovery Tool errors sent back to LLM, not crashes

Installation

Requires Python 3.10+.

pip install perm-agent

Or with uv:

uv add perm-agent

Configure your LLM provider

perm-agent uses litellm under the hood, so it works with 100+ LLM providers. Set the API key for your provider:

# OpenAI
export OPENAI_API_KEY="sk-..."

# Anthropic
export ANTHROPIC_API_KEY="sk-ant-..."

# Google Gemini
export GEMINI_API_KEY="AIza..."

# Azure OpenAI
export AZURE_API_KEY="..."
export AZURE_API_BASE="https://your-resource.openai.azure.com"

Model names follow litellm conventions: openai/gpt-4o, anthropic/claude-sonnet-4-20250514, gemini/gemini-2.0-flash, etc.


Usage guide

1. Define tools

Tools are regular Python functions. The @tool decorator auto-generates OpenAI-compatible function schemas from type hints and docstrings.

from perm_agent import tool

@tool
def search(query: str, max_results: int = 5) -> str:
    """Search the web for information.

    Args:
        query: The search query.
        max_results: Maximum results to return.
    """
    # Your implementation here
    return f"Results for: {query}"

@tool
def calculate(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

Supported parameter types: str, int, float, bool, list[T], dict, Optional[T], Literal[...], Enum, BaseModel, @dataclass.

2. Create an agent

from perm_agent import Agent

agent = Agent(
    name="assistant",
    model="openai/gpt-4o",
    instructions="You are a helpful assistant. Use tools when needed.",
    tools=[search, calculate],
)

result = agent.run("What is the mass of the sun in kilograms?")
print(result)

The agent will call tools as needed, feeding results back to the LLM until it produces a final answer.

3. Decorator style

For a more concise API, use the @agent decorator. The docstring becomes the agent's instructions.

from perm_agent import agent

@agent(model="openai/gpt-4o", tools=[search])
def researcher(question: str) -> str:
    """You are a research assistant. Find accurate information."""

answer = researcher("When was the transistor invented?")

4. Multi-agent pipeline

Chain multiple agents together. Each agent's output becomes the next agent's input.

from perm_agent import Agent, Pipeline

researcher = Agent(
    name="researcher",
    model="openai/gpt-4o",
    instructions="Research the topic thoroughly.",
    tools=[search],
)

writer = Agent(
    name="writer",
    model="openai/gpt-4o",
    instructions="Write a clear summary based on the research.",
)

pipeline = Pipeline("research-and-write")
pipeline.add_step(researcher, output_path="/research")
pipeline.add_step(writer, input_map={"input": "@:/research"}, output_path="/summary")

result = pipeline.run({"input": "History of quantum computing"})
print(result["summary"])

5. JSON workflows

The real power: define workflows as pure JSON. Store them in a database, load from an API, version with git.

import json
from perm_agent import build_agent_engine

def search(query: str) -> str:
    """Search the web."""
    return f"Results for: {query}"

engine = build_agent_engine(tools={"search": search})

workflow = [
    {
        "op": "agent_loop",
        "model": "openai/gpt-4o",
        "instructions": "Research the topic.",
        "input": "${/question}",
        "tools": ["search"],
        "path": "/research",
    },
    {
        "op": "llm",
        "model": "openai/gpt-4o",
        "messages": [
            {"role": "user", "content": "Summarize: ${@:/research}"}
        ],
        "path": "/summary",
    },
    {
        "op": "if",
        "cond": "${@:/summary}",
        "then": [{"op": "set", "path": "/status", "value": "done"}],
        "else": [{"op": "set", "path": "/status", "value": "failed"}],
    },
]

# The workflow is just a list of dicts -- save it anywhere
json.dumps(workflow)

# Execute
result = engine.apply(workflow, source={"question": "What is CRISPR?"}, dest={})
print(result["summary"])

6. Export and reconstruct agents

Any agent can be exported as a JSON spec and reconstructed later.

import json
from perm_agent import Agent

agent = Agent(
    name="analyst",
    model="openai/gpt-4o",
    instructions="Analyze the data.",
    tools=[search],
)

# Export to JSON
spec = agent.to_spec()
saved = json.dumps(spec)

# Later: reconstruct from JSON
loaded = json.loads(saved)
rebuilt = Agent.from_spec(
    {"name": "analyst", "model": "openai/gpt-4o", "instructions": "Analyze the data.", "tools": ["search"]},
    tools={"search": search},
)

result = rebuilt.run("Analyze trends in AI")

Available operations

All operations can be used in JSON workflow specs:

Operation Description
llm Single LLM call
tool Execute a registered tool
agent_loop Full agent loop with tool calling
handoff Delegate to another agent spec
set Set a value at a path
foreach Iterate over a collection
if Conditional branching
while Conditional loop
streaming_llm LLM call with token streaming
streaming_agent_loop Agent loop with streaming events

Plus j-perm built-ins: copy, delete, update, distinct, exec, assert, $def/$func (reusable functions), $eval (isolated sub-workflows), $or/$and/$not (logical operators).

Template syntax

  • ${/path} -- reference source data
  • ${@:/path} -- reference destination (previously computed) data
  • ${int:/age} -- cast to int
  • /path/- -- append to array

Advanced workflows

perm-agent supports declarative patterns that no other agent framework offers: reusable workflow functions, conditional fallbacks, and iterative loops -- all as JSON specs.

Reusable workflow functions ($def / $func)

Define a workflow once, call it multiple times with different inputs.

engine = build_agent_engine()

workflow = [
    # Define a reusable summarization function
    {
        "$def": "summarize",
        "params": ["text"],
        "body": [
            {
                "op": "llm",
                "model": "openai/gpt-4o",
                "messages": [
                    {"role": "user", "content": "Summarize in 2 sentences: ${/text}"},
                ],
                "path": "/result",
            },
        ],
        "return": "/result",
    },
    # Call it for each document
    {"/summary_a": {"$func": "summarize", "args": ["First document content..."]}},
    {"/summary_b": {"$func": "summarize", "args": ["Second document content..."]}},
]

result = engine.apply(workflow, source={}, dest={})
# result["summary_a"] and result["summary_b"] contain independent summaries

Fallback with $or

Try a primary path; if it returns empty/falsy, fall back to an alternative.

workflow = [
    {
        "/answer": {
            "$or": [
                [{"op": "set", "path": "/answer", "value": "${/cached_answer}"}],
                [{"op": "llm", "model": "openai/gpt-4o",
                  "messages": [{"role": "user", "content": "Answer: ${/question}"}],
                  "path": "/answer"}],
            ]
        }
    },
]

Iterative refinement with while

Loop until a condition is met.

workflow = [
    {"op": "set", "path": "/approved", "value": False},
    {
        "op": "while",
        "path": "@:/approved",
        "equals": False,
        "do": [
            {"op": "llm", "model": "openai/gpt-4o",
             "messages": [{"role": "user", "content": "Generate content"}],
             "path": "/output"},
            {"op": "if", "path": "@:/output",
             "then": [{"op": "set", "path": "/approved", "value": True}]},
        ],
    },
]

Isolated sub-workflows with $eval

Run a sub-workflow in an isolated context without polluting the main state.

workflow = [
    {"op": "set", "path": "/data", "value": "raw input"},
    {
        "/transformed": {
            "$eval": [
                {"op": "set", "path": "/x", "value": "processed"},
                {"op": "set", "path": "/temp", "value": "discarded"},
            ],
            "$select": "/x",
        }
    },
]
# result: {"data": "raw input", "transformed": "processed"}
# /temp stayed inside $eval's isolated context

Structured output

Validate LLM responses against Pydantic models.

from pydantic import BaseModel
from perm_agent import StructuredOutput

class UserProfile(BaseModel):
    name: str
    age: int
    interests: list[str]

output = StructuredOutput(UserProfile)

# Parse LLM response
profile = output.parse('{"name": "Alice", "age": 30, "interests": ["AI", "music"]}')
assert profile.name == "Alice"

# Safe parsing (returns None on failure)
result = output.parse_safe("invalid json")
assert result is None

# Get JSON schema for LLM prompting
schema = output.json_schema()

Observability

Built-in tracing inspired by OpenTelemetry. Every handler emits spans automatically.

from perm_agent import build_agent_engine, Tracer, ConsoleTracerHook, CostTracker

cost = CostTracker()
tracer = Tracer(hooks=[ConsoleTracerHook(), cost])

engine = build_agent_engine(tools={"search": search}, tracer=tracer)

spec = [
    {
        "op": "agent_loop",
        "model": "openai/gpt-4o",
        "instructions": "Research the topic.",
        "input": "Quantum computing",
        "tools": ["search"],
        "path": "/result",
    }
]

result = engine.apply(spec, source={}, dest={})

# Inspect spans
for span in tracer.spans:
    print(f"{span.operation}: {span.name} [{span.status}]")

# Token usage
print(f"Input tokens: {cost.total_input_tokens}")
print(f"Output tokens: {cost.total_output_tokens}")

# Export as JSON for log aggregation
trace_data = tracer.to_dict()

Traced operations: llm, tool, agent_loop, handoff, streaming_llm, streaming_agent_loop.


Streaming

Token-by-token streaming with an event system.

from perm_agent import build_agent_engine, TokenEvent, ToolCallEvent, AgentCompleteEvent

engine = build_agent_engine(tools={"search": search})

events_log = []

spec = [
    {
        "op": "streaming_agent_loop",
        "model": "openai/gpt-4o",
        "instructions": "Help the user.",
        "input": "Find info about Mars",
        "tools": ["search"],
        "on_event": lambda e: events_log.append(e),
        "path": "/result",
    }
]

result = engine.apply(spec, source={}, dest={})

for event in events_log:
    if isinstance(event, TokenEvent):
        print(event.token, end="", flush=True)
    elif isinstance(event, ToolCallEvent):
        print(f"\n[calling {event.tool_name}]")

Guardrails

Content filtering for inputs and outputs.

from perm_agent import MaxLengthGuardrail, ContentFilterGuardrail, GuardrailPipeline

pipeline = GuardrailPipeline([
    MaxLengthGuardrail(max_length=5000),
    ContentFilterGuardrail(blocked_patterns=["password", r"api[_-]?key"]),
])

result = pipeline.check("This is safe content")
assert result.passed

result = pipeline.check("My password is 12345")
assert not result.passed
print(result.reason)  # "Content matches blocked pattern: 'password'"

Retry

Automatic retry with exponential backoff for transient failures.

spec = [
    {
        "op": "llm",
        "model": "openai/gpt-4o",
        "messages": [{"role": "user", "content": "Hello"}],
        "retry": {"max_retries": 3, "backoff_factor": 1.0},
        "path": "/answer",
    }
]

Async

Full async support for all handlers.

from perm_agent import AsyncLlmHandler, AsyncAgentLoopHandler, AsyncToolHandler

handler = AsyncLlmHandler()
result = await handler.execute(
    model="openai/gpt-4o",
    messages=[{"role": "user", "content": "Hello"}],
)

Advanced tool schemas

The @tool decorator supports complex Python types:

from enum import Enum
from typing import Literal, Optional
from pydantic import BaseModel
from perm_agent import tool

class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class TaskInput(BaseModel):
    title: str
    description: str
    priority: Priority

@tool
def create_task(
    input: TaskInput,
    tags: list[str],
    assignee: Optional[str] = None,
    status: Literal["open", "closed"] = "open",
) -> str:
    """Create a new task in the system.

    Args:
        input: The task details.
        tags: Labels for categorization.
        assignee: Person responsible for the task.
        status: Current task status.
    """
    return f"Created: {input.title}"

Architecture

Python API (Agent, Pipeline, @agent)
         |
         v
    JSON Specs
         |
         v
   j-perm Engine
         |
    +---------+---------+---------+
    |         |         |         |
  llm      tool   agent_loop  handoff
    |         |         |         |
 litellm  registry  loop+tools  delegate
  • j-perm -- JSON transformation engine providing set, foreach, if, while, $def/$func, template resolution
  • litellm -- universal LLM API (100+ models)
  • pydantic -- structured output validation, tool schema generation

Publishing to PyPI

uv build
uv publish

Development

git clone https://github.com/denys/perm-agent.git
cd perm-agent
uv sync
uv run pytest
uv run ruff check src/ tests/

License

MIT

Metadata

Release files for perm-agent 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for perm-agent 0.3.0
File Size Uploaded Provenance
perm_agent-0.3.0.tar.gz 62.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for perm-agent 0.3.0
File Interpreter ABI Platform
perm_agent-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 94.6 kB

Release files / perm_agent-0.3.0.tar.gz

Download URL perm_agent-0.3.0.tar.gz
Size 62.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c231af784dac0c63208220a4e68e3e0a693189dbadab5b89fc9f9f41c390c937
BLAKE2b-256 checksum
How to use checksums
1813555a23aa9e69f02ee42b7eaff55f5691183cb69b58aa9b9ff2a32851ede9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / perm_agent-0.3.0-py3-none-any.whl

Download URL perm_agent-0.3.0-py3-none-any.whl
Size 32.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
174901440677d26b056f6026ee7fc654a1619a23fc3f581bfb88f41fc5d0882c
BLAKE2b-256 checksum
How to use checksums
58a071c8ce85a36685d9530c909e3b8599b5b843b34ab351846c20233b2cf214
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for perm-agent 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page