Skip to main content

elja

Elja (Icelandic): relentless drive — the quality of working at something without letting up.

A fully-customizable LLM agent harness built on Pydantic AI, designed for local-first models (LM Studio / OpenAI-compatible endpoints) with first-class support for tools, skills, sub-agents, MCP servers, and custom context management.

Status: under active development.

Installation

pip install elja

Usage

Start LM Studio serving a model (default expectation: qwen/qwen3.8-27b at http://localhost:1234/v1), then:

elja chat                      # interactive REPL (session persisted + resumed)
elja chat --once "list the files here and summarize them"
elja chat --once "what is in this screenshot?" --image shot.png
elja chat --config path/to/elja.toml --session mychat
# In the REPL, attach an image to a message with: /img <path> <prompt>

Or from Python:

from elja import EljaDeps, build_agent, build_usage_limits, load_settings

settings = load_settings()
agent = build_agent(settings)
result = agent.run_sync(
    "What's in this directory?",
    deps=EljaDeps.from_settings(settings),
    usage_limits=build_usage_limits(settings),
)
print(result.output)

Embedding elja in an application

build_agent is the convenience factory: it reads settings and assembles a complete local agent — workspace tools, filesystem skills, sub-agents, MCP clients, compaction, permission gate. A server that owns its own tools, dependencies and persistence wants the other door, which assembles nothing you did not ask for:

import asyncio
from dataclasses import dataclass

from pydantic_ai import RunContext
from pydantic_ai.toolsets import FunctionToolset

from elja import build_application_agent, build_model, load_settings


@dataclass
class AppDeps:            # your own type — no EljaDeps, no workspace
    tenant: str
    records: list[str]


toolset: FunctionToolset[AppDeps] = FunctionToolset()


@toolset.tool
async def record_fact(ctx: RunContext[AppDeps], fact: str) -> str:
    """A domain tool: it gets your deps object, untouched."""
    ctx.deps.records.append(fact)
    return f"recorded for {ctx.deps.tenant}"


# Your Model, passed through untouched. build_model(load_settings()) is one way
# to get one from elja.toml — that read is YOURS, not something this path does.
agent = build_application_agent(
    build_model(load_settings()),
    deps_type=AppDeps,
    instructions="You keep a household's records.",
    toolsets=[toolset],
)


async def main() -> None:
    result = await agent.run(
        "remember the vet is tuesday",
        deps=AppDeps(tenant="acme", records=[]),
    )
    print(result.output)


asyncio.run(main())

No file/shell/web-search tools, no skills directory scan, no MCP subprocess, no .elja writes, no workspace. The return value is a native pydantic_ai.Agent, so run_stream_events, message_history, output_type, per-run model_settings, usage and usage_limits all behave as they do upstream, and the standard agent options (name, retries, end_strategy, max_concurrency) are all reachable. A wall-clock cap per tool goes on your toolset (FunctionToolset(timeout=...)), not on the agent — see the module docstring for why.

elja's own capabilities stay available by opting in, e.g. capabilities=build_compaction(load_settings(), cleared_placeholder=...) — pass your own placeholder, because the default tells the model to re-run a cleared tool and names .elja/spill/, and neither is right for a host with side-effecting tools and no workspace. elja/application.py covers how None and empty differ between the two paths; elja/compaction.py covers composing the policy and why reporting goes last; and docs/EMBEDDING.md covers metering, admission control, events, checkpoints, cancellation, persistence, and the limits of what elja enforces, and examples/server_agent.py is all of it as a running server-shaped host, driven by the test suite so it cannot rot. Both live in the repository rather than the published package, so they are linked from github.com/benballintyn/elja for anyone reading this on PyPI.

Configuration lives in elja.toml (all keys optional; ELJA_* env vars override, e.g. ELJA_MODEL__BASE_URL):

[model]
provider = "openai"      # "openai" (any OpenAI-compatible endpoint — the default,
                         # aimed at LM Studio), "anthropic", or "google".
                         # Native providers need: pip install 'elja[anthropic]' / 'elja[google]'
name = "qwen/qwen3.8-27b"
base_url = "http://localhost:1234/v1"   # unset with provider="openai" = local LM Studio.
                         # provider="openai" NEVER means api.openai.com on its own —
                         # a hosted app must name the endpoint and model it intends.
temperature = 0.2        # omit the parameter entirely with temperature = ""
max_tokens = 4096        # (same: "" means "send no max_tokens"). Works in TOML
                         # and as ELJA_MODEL__TEMPERATURE="" in the environment.
# api_key: set for cloud endpoints; native providers also honor
# ANTHROPIC_API_KEY / GOOGLE_API_KEY from the environment.

# Any other native pydantic-ai ModelSettings key. Keys AND values are checked
# against the selected provider's own dialect, at every depth, so an unsupported
# key or a wrongly typed value is a config error naming its path rather than a
# 400 at request time (or, worse, a typo that silently disables a setting).
[model.settings]
top_p = 0.9
# Reasoning controls are the provider's own — no elja-side enum:
#   openai_reasoning_effort / anthropic_thinking / google_thinking_config,
#   or the portable `thinking` (true/false or "minimal".."xhigh").
# NB: pair a reasoning setting with temperature = "" rather than a value. Whether
# temperature/top_p survive is provider- AND model-specific, not a rule about
# reasoning: anthropic drops them per MODEL PROFILE, independent of thinking
# (claude-sonnet-5 drops them with a warning, claude-sonnet-4-5 does not); openai
# drops them at request time when reasoning is effectively active, which for the
# o-series and the original GPT-5 is the default; google sends them.

[limits]                 # every field maps to pydantic-ai's UsageLimits
request_limit = 25
total_tokens_limit = 120000
cost_limit = 2.50        # USD, and only for models pydantic-ai can price —
                         # on an unpriced model (the local default included) it
                         # is unenforced and warns once per completed run (the
                         # per-request checks pass warn_if_cost_unavailable=False),
                         # which the warnings registry then shows once per process.
tool_calls_limit = 40
input_tokens_limit = 100000
output_tokens_limit = 20000
per_request_input_tokens_limit = 30000   # enforced on every provider
count_tokens_before_request = false   # an extra count-tokens round trip, and
                         # ONLY supported by provider "anthropic" or "google";
                         # elja refuses it with "openai" rather than letting
                         # every request fail with NotImplementedError.

[workspace]
root = "."

[tools]
run_shell = true
web_search = true  # keyless DuckDuckGo search via ddgs (network egress!)

[agent]
instructions = "Optional: replace the default system instructions."

[permissions]          # per-tool policy: allow | ask | deny (any tool name,
default = "allow"      # incl. MCP tools and delegate_*). ask prompts y/N in the
                       # REPL and FAILS CLOSED when non-interactive.
[permissions.tools]
run_shell = "ask"      # the default: shell commands need a nod

[compaction]           # evidence-based tiered compaction (see elja/compaction.py)
                       # NB: 24000 is tuned for a local 27B; raise it for large-window
                       # cloud providers to avoid early masking + paid summarizer calls
enabled = true
target_tokens = 24000  # load the LM Studio model with at least this + headroom
keep_tool_pairs = 10   # recent tool results kept verbatim by the masking tier
keep_messages = 20     # verbatim tail if the summarization fallback fires

# Skills: markdown files in <workspace>/skills/ (or [skills] dir = "...") with
# YAML frontmatter (id, description) + an instructions body. The model loads
# them on demand, so a large skill library costs ~no context until used.

# Sub-agents: delegate tools with isolated context (results-only return).
[subagents.researcher]
description = "Researches a question and reports key facts."
instructions = "Answer tersely with sources."
tools = ["read_file", "web_search"]  # subset of ENABLED built-ins; omit for all enabled
request_limit = 8                     # per-delegation request budget (optional)

# Attach MCP servers; their tools become available to the agent.
[mcp.servers.mytools]
command = "python3"         # stdio: launched as a subprocess
args = ["my_mcp_server.py"]
env = { API_KEY = "..." }   # optional; NB the subprocess gets a minimal env
                            # (HOME/PATH/USER...) plus these — not your full shell env

[mcp.servers.remote]
transport = "http"          # streamable-HTTP endpoint
url = "http://localhost:9000/mcp"
headers = { Authorization = "Bearer ..." }  # optional auth headers
tool_prefix = "remote"      # optional: tools appear as remote_<name>; NB [permissions.tools]
                            # entries must then use the prefixed name
init_timeout = 30           # optional: seconds for slow (npx/uvx) server startup

Development

poetry install --with dev
poetry run pre-commit install

Run checks:

poetry run ruff check src tests
poetry run mypy
poetry run pytest -m "not integration"

Integration tests (-m integration) require a running LM Studio server at http://localhost:1234/v1.

License

MIT

Metadata

Release files for elja 0.5.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 elja 0.5.0
File Size Uploaded Provenance
elja-0.5.0.tar.gz 49.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for elja 0.5.0
File Interpreter ABI Platform
elja-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 102.9 kB

Release files / elja-0.5.0.tar.gz

Download URL elja-0.5.0.tar.gz
Size 49.4 kB
Tags Source
SHA-256 checksum
How to use checksums
ad11df67e454554175a71c8dca02ff42a0bd9365fd7a1565b81016c9bb3f742c
BLAKE2b-256 checksum
How to use checksums
f95a688f28f6ad361bf9737e183adf856bd83d26e19a9dbb23f00131ff95a59a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / elja-0.5.0-py3-none-any.whl

Download URL elja-0.5.0-py3-none-any.whl
Size 53.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
559e1cddd13789bfc77357e9c33de9f585eb1452c1370779d4e2224af25d43de
BLAKE2b-256 checksum
How to use checksums
924bec8e397ea413d18717e4de907b1cdff975a92d41264df6ecb140fc558133
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Security for elja 0.5.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.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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