Metadata-Version: 2.3
Name: europacore
Version: 0.3.0
Summary: Agent and harness core for building agent-based applications: a multi-turn tool-calling harness with guardrails, checkpoint/resume, human-in-the-loop, compaction and sub-agent delegation.
License: MIT
Author: Mark Moloney
Author-email: m4rkmo@gmail.com
Requires-Python: >=3.11,<4.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Provides-Extra: bedrock
Provides-Extra: bedrock-openai
Provides-Extra: browser
Provides-Extra: claude-code
Provides-Extra: costs
Provides-Extra: credentials
Provides-Extra: graph
Provides-Extra: images
Provides-Extra: mcp
Provides-Extra: openai
Provides-Extra: openai-responses
Provides-Extra: otel
Provides-Extra: postgres
Provides-Extra: promptstore
Provides-Extra: sandbox
Provides-Extra: server
Provides-Extra: telemetry
Provides-Extra: temporal
Requires-Dist: aws-bedrock-token-generator (>=1.1.0,<2.0.0) ; extra == "bedrock-openai"
Requires-Dist: boto3 (>=1.43.37,<2.0.0) ; extra == "bedrock"
Requires-Dist: boto3 (>=1.43.37,<2.0.0) ; extra == "sandbox"
Requires-Dist: botocore[crt] (>=1.43.37,<2.0.0) ; extra == "bedrock"
Requires-Dist: botocore[crt] (>=1.43.37,<2.0.0) ; extra == "bedrock-openai"
Requires-Dist: cel-python (>=0.5,<0.6) ; extra == "graph"
Requires-Dist: claude-agent-sdk (>=0.2.168,<0.3.0) ; extra == "claude-code"
Requires-Dist: cryptography (>=42.0.0,<51.0.0) ; extra == "credentials"
Requires-Dist: fastapi (>=0.115,<1.0) ; extra == "server"
Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
Requires-Dist: litellm (>=1.75.2,<2.0.0) ; extra == "costs"
Requires-Dist: mcp (>=2,<3) ; extra == "mcp"
Requires-Dist: openai (>=1.74.1,<2.0.0) ; extra == "openai"
Requires-Dist: opentelemetry-api (>=1.27,<2) ; extra == "otel"
Requires-Dist: pillow (>=10.0,<13) ; extra == "images"
Requires-Dist: playwright (>=1.54.0,<2.0.0) ; extra == "browser"
Requires-Dist: promptstore-telemetry (>=0.1.3,<0.2.0) ; extra == "telemetry"
Requires-Dist: promptstorepy (>=0.1.2,<0.2.0) ; extra == "promptstore"
Requires-Dist: psycopg[binary] (>=3.2.9,<4.0.0) ; extra == "postgres"
Requires-Dist: pybars3 (>=0.9.7,<0.10.0)
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
Requires-Dist: pydantic-graph (>=2,<3) ; extra == "graph"
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
Requires-Dist: requests (>=2.32.4,<3.0.0) ; extra == "bedrock-openai"
Requires-Dist: requests (>=2.32.4,<3.0.0) ; extra == "openai-responses"
Requires-Dist: requests (>=2.32.4,<3.0.0) ; extra == "sandbox"
Requires-Dist: requests-sse (>=0.3.2,<0.4.0) ; extra == "bedrock-openai"
Requires-Dist: requests-sse (>=0.3.2,<0.4.0) ; extra == "openai-responses"
Requires-Dist: temporalio (>=1.14.1,<2.0.0) ; extra == "temporal"
Requires-Dist: tenacity (>=9.1.2,<10.0.0) ; extra == "bedrock"
Requires-Dist: tenacity (>=9.1.2,<10.0.0) ; extra == "bedrock-openai"
Requires-Dist: tenacity (>=9.1.2,<10.0.0) ; extra == "openai-responses"
Requires-Dist: tiktoken (>=0.9.0,<0.10.0)
Requires-Dist: typing-extensions (>=4.14.1,<5.0.0)
Requires-Dist: uvicorn (>=0.30,<1.0) ; extra == "server"
Description-Content-Type: text/markdown

# europacore

The agent and harness core for building agent-based applications. It includes:

- **`AgentHarness`**: a multi-turn, tool-calling loop with:
  - guardrails
  - checkpoint/resume and human-in-the-loop approval
  - streaming
  - provider-agnostic retry
  - context compaction
  - sub-agent delegation
  - opt-in failure-spend governance (`failure_spend_governance=True`): a fan-out cap, a circuit breaker for identically failing calls, and a failure budget that steers a failing run to answer early (`failure_arm_after_distinct_calls=2` arms it only once two different calls have failed, for tools that report ordinary misses as errors)
- **`SingleShotAgent`**: one prompt in, one schema-validated output, in a single forced LLM call.
- **`ToolAgent`**: the base class for the tools the harness dispatches.
- **Built-in tools** for planning, skills and delegation, each enabled by a flag. `enable_planning=True` registers `todo_write` and, by default, nudges the model to plan before substantial work; `planning_mode="auto"` keeps the tool and drops the nudge, so a one-line question costs no extra round-trip.
- **An LLM layer**: a Chat-Completions-shaped `LLM` contract with Amazon Bedrock (Claude) and OpenAI (Responses API) providers, plus a `(name, version)` prompt registry.
- **Optional durable state and runtimes**:
  - Postgres checkpointer and run journal
  - a Temporal-backed autonomous multi-agent runtime
  - sandbox providers (local subprocess, AWS Lambda MicroVMs)

Moving code from the library's predecessor? See [`docs/migration.md`](docs/migration.md).

## API reference and compatibility

[`docs/api.md`](docs/api.md) lists every public name with its signature and summary; anything not listed there is internal. [`docs/deprecations.md`](docs/deprecations.md) says what a release may change and how renamed names are phased out.

## Install

Python 3.11+.

```bash
pip install -e ../europacore                      # core only
pip install -e '../europacore[bedrock,postgres]'  # with extras
```

As a Poetry path dependency:

```toml
europacore = { path = "../europacore", develop = true, extras = ["bedrock", "postgres"] }
```

The core needs pydantic, pyyaml, jinja2, pybars3 and tiktoken. Everything else is an extra, and is imported only when you use it:

| Extra | Enables |
|---|---|
| `bedrock` | `europacore.llm.bedrock`: `BedrockClaudeModel`, `BedrockEmbeddingModel`. A call carrying a cancel token (every harness call) goes over `converse_stream`, even when not streaming, so a cancel stops it: grant `bedrock:InvokeModelWithResponseStream` as well as `bedrock:InvokeModel`; without it the model warns once and falls back to `converse`, whose cancelled calls run to the end. `BedrockClaudeModel(performance_config={"latency": "optimized"})` asks for latency-optimised inference, kept only on the model/region pairs Bedrock accepts it for (`LATENCY_OPTIMIZED_SUPPORT`); `BedrockConfig.get_performance_config(model_id, region)` is the opt-in lookup for a model factory. |
| `openai` | `europacore.llm.openai`: embedding and transcription models |
| `openai-responses` | `from europacore.llm.openai.OpenAIModel import OpenAIModel`: chat models on the OpenAI Responses API, over `requests`/`requests-sse` (no `openai` SDK). Calls the harness drives make a single attempt, so the harness is the only retry layer; `external_retry=True` does the same for direct calls. A non-streaming call carrying a cancel token (every harness call) is sent over the event stream and returns only the final response, so a cancel closes the connection. |
| `bedrock-openai` | `from europacore.llm.bedrock.BedrockOpenAIModel import BedrockOpenAIModel`: GPT models on Bedrock's OpenAI-compatible endpoint, minting short-term Bedrock API keys from AWS credentials |
| `postgres` | `PostgresHarnessCheckpointer`, `PostgresHarnessJournal`, `PostgresAutonomousRunStore` |
| `temporal` | `europacore.autonomous.temporal.TemporalAutonomousCoordinator` |
| `sandbox` | `from europacore.sandbox.LambdaMicroVmProvider import LambdaMicroVmProvider` |
| `browser` | in-process Playwright browser ops for the local sandbox |
| `promptstore` | remote prompt sets (`template_source` other than `"local"`) |
| `telemetry` | PromptStore tracing of runs, model calls and tools (no-op without it) |
| `costs` | USD cost estimates in `HarnessResult` for models beyond the bundled catalog (via litellm prices) |
| `mcp` | `europacore.agents.tools.mcp_tools.McpToolset`: an MCP server's tools as harness tools (MCP SDK 2.x) |
| `claude-code` | `europacore.claude_code`: Claude Code sessions served next to `coder` by the coding server, running Claude Code's own agent loop through the Claude Agent SDK ([docs/claude-code.md](docs/claude-code.md)) |
| `otel` | OpenTelemetry spans and metrics (GenAI semantic conventions) for runs, model calls and tools |
| `server` | `europacore.server`: sessions, runs and resumable SSE streams over HTTP (FastAPI, uvicorn), and the `europacore serve` command |
| `credentials` | `cryptography`, for the default encryptor of stored tool credentials (`europacore.credentials.FernetEncryptor`) |
| `graph` | `europacore.graph`: FlowScript flows (`.flow`) compiled onto pydantic-graph and run by `FlowRunner` — pauses and resumes, sub-flows, background work, harness nodes on `AgentHarness`, flow and secret stores; with `server`, the `/v1/graph` routes and the designer at `/graph/ui` |
| `images` | Pillow, so images a tool returns (`read_file` on a screenshot) are scaled to at most 1568 px and 750 KB before they are sent (`europacore.utils.images.fit_image`); without it they are sent as they are, up to 3.75 MB |

`poetry install --all-extras` installs all of them. There's no `all` extra, because Poetry 2.0 rejects self-referencing extras and a duplicated list makes the resolver explode.

## Quickstart

An agent that runs offline on a core-only install ([`examples/minimal_agent.py`](examples/minimal_agent.py)):

```python
from europacore.agents import AgentHarness, function_tool
from europacore.testing import ScriptedLLM, text, tool_call

@function_tool
def add(a: float, b: float) -> float:
    """Add two numbers."""
    return a + b

llm = ScriptedLLM([tool_call("add", {"a": 2, "b": 40}), text("2 + 40 = 42")])   # plays the model
agent = AgentHarness(llm, tools=[add], system_prompt="You are a careful calculator.")
print(agent(question="What is 2 + 40?").text)
```

To go live, pass a real model instead of `ScriptedLLM`, such as `create_claude_haiku_4_5()` below.

### With Bedrock, a prompt template and a structured answer

This needs the `bedrock` extra and AWS credentials.

```bash
pip install -e '../europacore[bedrock]'
export EUROPACORE_TEMPLATE_DIRS=./templates   # where calculator/v1.0.0/calculator.yaml lives
```

```python
from pydantic import BaseModel
from europacore.agents import AgentHarness, InMemoryHarnessCheckpointer, ToolAgent, ToolResult
from europacore.llm.bedrock.bedrock_config import create_claude_haiku_4_5


class AddInput(BaseModel):
    a: float
    b: float


class AddTool(ToolAgent):
    description = "Add two numbers."
    input_schema = AddInput

    def __init__(self, **kwargs):
        super().__init__(name="add", **kwargs)

    def execute(self, input: AddInput) -> ToolResult:
        return ToolResult(ok=True, content=str(input.a + input.b))


class Answer(BaseModel):
    answer: str


agent = AgentHarness(
    llm=create_claude_haiku_4_5(),
    tools=[AddTool()],
    output_schema=Answer,                    # the model must finish by calling output_formatter
    template=("calculator", "1.0.0"),        # <template dir>/calculator/v1.0.0/calculator.yaml
    checkpointer=InMemoryHarnessCheckpointer(),
)

result = agent(question="What is 2 + 40?", session_id="s1")
print(result.output)                         # {'answer': '...'}

for event in agent.stream(question="And 3 + 4?", session_id="s2"):
    ...                                      # HarnessEvent(type=...), then the HarnessResult
```

A tool can also carry rules on when and how to use it, as `prompt_guidelines` (a tuple of strings). The harness lists every loaded tool's rules once, under "Using the tools:", after the system prompt, so a rule only reaches the model when its tool is loaded. `@function_tool(prompt_guidelines=[...])` (below) takes them too.

A function becomes a tool with `@function_tool`: its parameters become the input schema, the docstring's first paragraph the description and its `Args:` lines the field descriptions. The recovery metadata a `ToolAgent` declares as class attributes are keyword arguments:

```python
from europacore.agents import function_tool

@function_tool(retry_safety="read_only", side_effect="read_only")
def add(a: float, b: float) -> float:
    """Add two numbers.

    Args:
        a: The first addend.
        b: The second addend.
    """
    return a + b

agent = AgentHarness(create_claude_haiku_4_5(), tools=[add], system_prompt="You are a careful calculator.")
```

A parameter named `context` receives the per-call context described next. The defaults are the conservative `ToolAgent` ones, so declare a read-only tool as such: a non-idempotent tool in flight at a crash waits for a human instead of running again.

An MCP server's tools join a harness through `McpToolset` (the `mcp` extra):

```python
from mcp import StdioServerParameters
from europacore.agents.tools.mcp_tools import McpToolset

with McpToolset(StdioServerParameters(command="github-mcp-server", args=["stdio"]), name="github") as github:
    agent = AgentHarness(llm, tools=github.tools(), system_prompt="...",
                         extra_guardrails=[PermissionGuardrail([("mcp:github:create_*", "ask")])])
```

- **Session.** The toolset keeps one session open until it is closed. `server` is anything the MCP SDK's `Client` accepts: stdio parameters, a streamable-HTTP URL, or an in-process server.
- **Permissions.** Each call is gated as `mcp:<name>:<tool>` as well as by its tool name.
- **Selection.** `tool_filter` and `prefix` select and rename tools.
- **Annotations.** A server's annotations are trusted for recovery metadata only with `trust_annotations=True`.

A tool that needs the per-call context (the cancel event, its call id, the run's remaining budget) sets `wants_context = True` and defines `execute(self, input, context)`. A tool that forwards to other tools, such as an MCP gateway, can override `start_event_data(args)` to name the operation on its `tool-call-start` event, and `gated_operation(args)` so that `PermissionGuardrail` rules and approvals see the remote operation rather than the gateway.

Without an `output_schema`, the run ends on the model's first reply with no tool calls, and that reply is the answer. No `output_formatter` tool is declared, so a one-shot question costs one model call, not two:

```python
agent = AgentHarness(create_claude_haiku_4_5(), system_prompt="Answer in one sentence.")
print(agent(question="What is the capital of France?").text)   # output == {"text": "..."}
```

When a cap stops such a run, the forced final answer is also asked for as text. `soft_force_terminal_at_step` and guardrails that override `on_terminal` work on the `output_formatter` call, so they need an `output_schema`; passing them without one raises at construction.

A template that cannot be found raises at construction, listing every directory searched. For a quick agent or a test, pass `system_prompt="..."` instead of `template=`; the user turn is then the `question` argument. The template file is the one in [`examples/templates/calculator`](examples/templates/calculator).

[`examples/template_agent.py`](examples/template_agent.py) is the same agent with a scripted model, so it runs offline on a core-only install. `europacore.testing` provides that model (`ScriptedLLM`) and response builders (`tool_call`, `final`, `text`) for testing your own agents. A script item may also be an exception to raise, or a callable `(request) -> response` that answers from what the model was sent. Run it with `poetry run python examples/template_agent.py`.

To test against real model behaviour offline, record a run once and replay it in CI:

```python
from europacore.testing import RecordingLLM, ReplayLLM, frozen_clock, sequential_ids

with frozen_clock(datetime(2030, 1, 1)), sequential_ids():   # a stable prompt date and ids
    run_agent(RecordingLLM(create_claude_haiku_4_5(), "tests/cassettes/triage.jsonl"))   # live, once

with frozen_clock(datetime(2030, 1, 1)), sequential_ids():
    run_agent(ReplayLLM("tests/cassettes/triage.jsonl"))   # offline
```

- **What a cassette holds.** It is JSON lines: the recorded model's attributes, then each completed call's request, response and streamed chunks, redacted for credentials. Failed attempts are not recorded.
- **Strict replay.** `ReplayLLM` serves the calls in order and raises `CassetteMismatch` when a request differs from the recorded one, showing both keys and each side's last message (`strict=False` skips the check), so a change in what the harness sends fails the test.
- **Evaluation.** `europacore.evaluation` scores an agent against an eval set (JSON). Each case declares the tool calls it expects (`exact`, `in_order` or `any_order`), an output to contain, and rubric statements a judge model scores:

  ```python
  from europacore.evaluation import EvalSet, RubricJudge, evaluate

  def test_triage():
      evaluate(build_agent, EvalSet.load("evals/triage.json"), judge=RubricJudge(judge_model)).assert_passed()
  ```

  A failing case's summary shows the tools it called, its answer and the rubrics it missed. `runs=` averages repeated runs, and `threshold=` sets the bar per metric.
- **Reproducible runs.** `frozen_clock` and `sequential_ids` fix every timestamp and generated id europacore records: run, turn, event and pause ids, checkpoint times and the prompt's `current_date`. They are process-wide for the `with` block.

A missing optional dependency raises `MissingExtraError` (a `ModuleNotFoundError`) naming the extra to install.

## Constructor reference

`AgentHarness(llm, output_schema=None, tools=(), *, ...)`: the model (`llm`), the terminal output schema (`output_schema`, optional: without one the final text reply is the answer) and the tool list (`tools`) are positional; everything else is keyword-only. Grouped by what it controls; the defaults are the ones that apply when the argument is omitted.

The same arguments also come in groups, one per heading below, from `europacore.agents.options`, and `AgentHarness` takes them directly:

```python
from europacore.agents import AgentHarness
from europacore.agents.options import Limits, ModelCall, Prompt

agent = AgentHarness(
    llm,
    tools=[...],
    prompt=Prompt(system_prompt="You triage incidents."),
    limits=Limits(max_steps=40, max_cost_usd=2.0),
    model_call=ModelCall(stream_tokens=True),
)
```

| Keyword | Group | Heading |
|---|---|---|
| `prompt` | `Prompt` | Prompt and identity |
| `model_call` | `ModelCall` | Model call |
| `limits` | `Limits` | Limits and cost |
| `tool_dispatch` | `ToolDispatch` | Tools and dispatch |
| `guardrails` | `Guardrails` | Guardrails and loop detection |
| `compaction` | `Compaction` | Compaction |
| `durability` | `Durability` | Durability |
| `sandbox` | `Sandbox` | Sandbox and permissions |
| `collaborators` | `Collaborators` | Replacing a collaborator |

Flat and grouped arguments mix: a group's fields left at their defaults leave the flat arguments alone, and an argument set both flat and in a group raises `TypeError`. The built-in features (Capabilities) keep their flat arguments and their own option types; `build_harness(llm, ..., features=[PlanningOptions(...)])` takes those alongside the groups. A test keeps each group's defaults equal to the constructor's.

**Prompt and identity**

| Argument | Default | |
|---|---|---|
| `template` / `system_prompt` | none | Exactly one: a `(name, version)` template or an inline system prompt (see Prompt templates) |
| `template_source` | `"local"` | Where `template` is looked up |
| `extra_system_prompt` | none | Appended to the system prompt |
| `name`, `description`, `input_schema` | none | How the harness presents itself when used as another agent's tool |
| `prompt_registry`, `token_checker` | `europacore.configure()` defaults | See Application defaults |

**Model call**

| Argument | Default | |
|---|---|---|
| `llm_params` | none | Request parameters sent with each call (never assigned onto a shared model). `{"thinking": "off" \| "low" \| "medium" \| "high"}` sets reasoning depth on any provider: Bedrock Claude sends adaptive thinking with `output_config.effort` (`off` sends no thinking config; a 5.x model, which thinks by default, is sent `{"type": "disabled"}`, except Fable 5 and Mythos 5, which reject it and get effort `low`), OpenAI sends `reasoning.effort` (`off` is `none`) unless `reasoning` is set |
| `callback_handler` | a new `CallbackHandler` | Receives harness spans and this harness's model calls. Tools get it per call in their context (`callbacks.CALLBACK_HANDLER_CONTEXT_KEY`); it is never assigned onto a tool |
| `callbacks` | none | `Callback`s registered on that handler at construction |
| `stream_tokens` | `False` | Yield `assistant-delta`/`thinking-delta` events while the model streams |
| `stream_idle_timeout` | `300.0` s | A stream silent this long is retried as stalled |
| `llm_max_attempts`, `llm_retry_base_delay`, `llm_retry_max_delay`, `llm_retry_max_total` | `5`, `1.0`, `120.0`, `300.0` s | The single retry authority: bounded backoff on transient errors, never past `max_seconds` |
| `enable_recoverable_pause` | `True` | Pause for an operator on a fixable model error (bad model id, expired credentials) instead of failing |
| `finalize_max_attempts` | `2` | Attempts at a forced final answer |

**Limits and cost**

| Argument | Default | |
|---|---|---|
| `max_steps` | `25` | Model calls per turn |
| `max_seconds` | `300.0` | Wall clock per turn, including tool waits and retries |
| `max_total_tokens` | `EUROPACORE_MAX_TOTAL_TOKENS`, else max(500k, 2.5 × window) | Tokens per turn, including tool and compaction calls |
| `max_session_tokens` | none | Tokens across a session |
| `max_cost_usd` | none | USD per turn; needs prices (the `costs` extra or `cost_estimator`), warns at construction otherwise |
| `cost_estimator` | bundled catalog, then litellm price map | `(model, usage) -> USD or None` |
| `soft_force_terminal_at_step` | none | From this step, require the terminal tool |

**Tools and dispatch**

| Argument | Default | |
|---|---|---|
| `parallel_tool_calls` | `True` | Run a turn's calls concurrently (gated per tool); off keeps every call on the calling thread, except tools that declare `timeout_seconds`, which run alone on a worker so they can be timed out |
| `tool_cancel_grace_seconds` | `5.0` | How long running tools get to observe a cancel |
| `overlap_tool_dispatch` | `EUROPACORE_OVERLAP_TOOL_DISPATCH`, else off | Start read-only tools while the response still streams |
| `tool_result_max_chars` | `8000` | Larger results are stored behind a handle and previewed |
| `tool_result_store`, `tool_result_store_max_entries`, `tool_result_store_max_chars` | in memory, `64`, `1,500,000` | Where those results live, read back with `read_tool_result` |
| `large_prompt_threshold_chars`, `large_prompt_preview_chars` | `25000`, `25000` | The same for oversized user messages |
| `image_spool`, `max_inline_images` | none, `4` | Durable store for attached images; images sent inline per request |
| `credentials` | none | The tool credentials of the person the harness acts for (`CredentialService.for_owner(workspace_id, user_id)`); see [Tool credentials](#tool-credentials-europacorecredentials) |

**Guardrails and loop detection**

| Argument | Default | |
|---|---|---|
| `extra_guardrails` | none | Appended to the built-in chain each run |
| `stuck_detection`, `stuck_exempt_tools` | `True`, none | Repeated-call and repeated-result detection; tools whose repetition is the job |
| `failure_spend_governance`, `failure_patterns`, `failure_arm_after_distinct_calls` | off, built-in table, `1` | Opt-in failure budget and circuit breaker (`guardrails/spend.py`) |
| `context_fragments` | none | Context blocks composed into the prompt each turn |

**Compaction**

| Argument | Default | |
|---|---|---|
| `history_compaction_threshold` | `EUROPACORE_COMPACTION_THRESHOLD`, else 0.65 | Fraction of the window that triggers compaction (clamped to leave one step of headroom) |
| `compaction_llm` | `europacore.configure(compaction_llm_factory=)` | Summarises dropped turns; none means a structural digest |

**Capabilities**

| Argument | Default | |
|---|---|---|
| `enable_planning`, `planning_mode` | off, `"plan"` | The `todo_write` tool; `"auto"` registers it without the prompt nudge |
| `enable_skills`, `skills` | off, none | Loadable skills: a dict of `LoadableSkill`. `skills=` without `enable_skills=True` raises `ValueError` (so does `subagents=` without `enable_delegation=True`) |
| `enable_delegation`, `subagents`, `max_delegation_depth`, `temporal_delegation` | off, none, `2`, off | Sub-agent delegation, optionally through Temporal. A child never gets more tokens, time or cost than its parent has left; under Temporal, cap the child you build with `RemainingBudget.from_payload(pause["budget"]).cap(child)` |
| `tools=[SubagentTool(name, factory, description=..., input_schema=...)]` | | One sub-agent as its own named, typed tool, run like a delegated child (budget, depth, cancel, resumable pauses). `europacore.graph.flow_tool` builds one for a flow |
| `scratchpad_max_keys`, `scratchpad_value_max_chars` | none | Deprecated and ignored (`harness.scratchpad` had no reader); removed in 0.3 |

**Durability**

| Argument | Default | |
|---|---|---|
| `checkpointer` | none | Session state; needed for `session_id`, pause and resume. A `session_id` without one is stateless, and the harness logs a warning once |
| `journal` | the checkpointer's, else none | Event journal: leases, replay, uncertain tool outcomes |
| `lease_heartbeat_interval_seconds` | `30.0` | How often an open run renews its lease |
| `auto_retry_safe_tool_calls` | `False` | When a run resumes in place after a crash, run the read-only and idempotent tool calls it left without an outcome again, instead of telling the model their outcome is unknown |

**Sandbox and permissions**

| Argument | Default | |
|---|---|---|
| `permission_profile` | derived from the tools | Filesystem roots, network domains and capabilities tools may use |
| `sandbox_backend`, `require_sandbox_backend` | none, `False` | Where sandboxed tools run; whether running without one is an error |
| `sandbox_escalation`, `sandbox_escalation_reasons` | off, filesystem root + network domain | Pause for a human on a widenable denial instead of failing the call |

**Replacing a collaborator** (each default is behaviour-preserving, and `tests/test_harness_seams.py` shows a minimal replacement)

| Argument | Contract |
|---|---|
| `llm_invoker` | `harness_llm.LLMInvoker`: the model call and its retries |
| `token_estimator` | `harness_compaction.TokenEstimatorProtocol` |
| `compaction_policy` | `harness_compaction.CompactionPolicy` |
| `message_builder` | `harness_host.MessageBuilderProtocol`: prompt and image assembly |
| `checkpoint_writer` | `harness_checkpoint.CheckpointWriter`: durable save and load |

## Events

`stream()` yields `HarnessEvent`s, then the `HarnessResult`. A durable journal stores each event as it is yielded, so the names are a public contract: they are listed in `europacore.agents.HarnessEventType`, and a test fails when the library emits a name missing from it. The loop's own events are kebab-case; an event a guardrail's `Decision` names is snake_case and carries the decision's payload as `data` (with the guardrail's name under `guardrail` when the chain adds it). A custom guardrail may emit any name.

| Type | When |
|---|---|
| `agent-loop-start` | A run starts |
| `agent-loop-end` | A run ends with an answer or an error (`data.output` on success) |
| `agent-loop-paused` | A run pauses for a decision; the result carries the `pause` payload |
| `agent-loop-resume` | `resume()` continues a paused run |
| `agent-recovery-safe-retry` | A restarted run found tool calls whose outcome was lost and that may run again (`data.outcomes`): read-only or idempotent calls, and calls whose tool's `reconcile` said their effect did not land. They run again with `auto_retry_safe_tool_calls`; otherwise the model is told the outcome is unknown (or, for an effect that did not land, that the call did not run) |
| `agent-tool-reconciled` | A restarted run asked a non-idempotent tool's `reconcile` about a call whose outcome was lost, and the effect had landed (`data.outcome`); the call is answered with the result `reconcile` returned |
| `agent-duplicate-request` | The call's `request_id` was already answered: the earlier run's result follows, and nothing runs (`data.request_id`, `data.run_id`) |
| `agent-step-start`, `agent-step-end` | Each model step; a forced final call has `data.final_attempt` |
| `agent-compaction` | History was compacted to fit the context window |
| `assistant-delta`, `thinking-delta` | Streamed text and reasoning chunks (`stream_tokens=True`) |
| `assistant-message`, `thinking` | The model's complete text and reasoning for a step |
| `token-usage` | The step's model usage |
| `tool-call-start`, `tool-call-end` | Each tool call (`name` is the tool) |
| `tool-call-progress` | A running tool's report (`report_progress`), e.g. the latest output of `exec_command`; live only, never journaled |
| `tool-call-delta` | A tool call's argument text as the provider streams it (`stream_tokens=True`; `data.index`, `data.name` on the first chunk, `data.arguments`); live only, never journaled |
| `response-images` | The model returned images (`data.count`) |
| `response-empty-retry` | An empty reply was reissued once with a nudge |
| `response-truncated` | A reply hit the output token limit |
| `steering-injected` | Steering messages were added to the history |
| `cap_hit` | A cap was reached (`data.code`): `max_steps` and the token caps force a final answer, others (`max_seconds`, cost) fail the run |
| `no_tool_call` | The model replied without a tool call and was nudged |
| `terminal_validation_failed` | The `output_formatter` call was rejected (`data.reason`: `json`, `schema` with `data.errors`, or `missing_candidate`) |
| `stuck_same_tool_overuse`, `stuck_repeated_results` | Loop detection fired |
| `tool_call_budget`, `tool_circuit_break`, `failure_budget` | A spend guardrail refused a call or forced a final answer |
| `permission_denied`, `approval_required`, `policy_escalation_required` | `PermissionGuardrail`/`RequiresApprovalGuardrail` denied or paused a call |
| `credential_required` | A tool needs a credential the person has not connected; a `needs_credential` pause follows (see [Tool credentials](#tool-credentials-europacorecredentials)) |
| `subagent_approval_required`, `temporal_delegation_requested` | A delegated child needs approval, or is deferred to Temporal |
| `temporal_step_boundary` | The run pauses between steps so a Temporal activity can checkpoint |
| `insufficient_info` | The sufficiency guardrail ended the run asking for more information |
| `tool_whitelist_block` | A tool outside the allowlist was refused |
| `authorization_check_failed` | A `failure_mode="closed"` guardrail raised, failing the run |
| `message_size_truncated`, `message_size_nudge` | `MessageSizeBudgetGuardrail` truncated or flagged an oversized message |
| `agent-waiting-children` | An autonomous run waits on its children (kebab-case for historical reasons) |
| `prerequisite_nudge`, `required_tools_failed` | A flow's harness node answered before calling its `required_tools` and was nudged, or gave up after `max_terminal_attempts` |
| `prerequisite_violation` | A flow's harness node called a tool before its `tool_prerequisites` |
| `validation_required` | The flow designer's assistant tried to finish before `validate_syntax` passed |
| `hook_message` | A coding agent's command hook said something for the user (`data.event`, `data.message`: its `systemMessage`, an error, or why it was not honoured) |
| `design_findings` | The coding agent's design gate found blocking findings its change introduced (`data.findings`); `data.unresolved` is true when it stopped sending the agent back (its rounds are used up, or a round fixed nothing) |
| `claude_config_warnings` | Once per session: what in the repository's or user's Claude Code configuration the coding agent does not honour (`data.warnings`) |
| `flow-run-start`, `flow-run-end` | A flow turn (`europacore.graph`) begins and ends (`data.status`) |
| `flow-node-start`, `flow-node-end` | A flow node is about to run / ran (`name` is the node; `data.status`, `data.next`, `data.result`, a size-bounded `data.state`) |
| `flow-route` | A node was skipped by `enabled_when` or a rejected approval, or a `wait` node received its event |
| `flow-message` | The flow showed the user a message (`data.message`) |
| `flow-paused` | An approval gate paused the run before a node (`data.prompt`) |
| `flow-cutoff` | The turn hit `max_nodes_per_turn` |
| `flow-background-start`, `flow-background-end` | Background children started / were collected |
| `flow-fork-start`, `flow-fork-end` | A `parallel` or `map` node's children started / all finished |
| `flow-node-retry` | A node with a `retry` policy failed and runs again |
| `flow-field-delta` | A harness node's `streamable_output_field` as the model writes it (`data.field`, `data.delta`); live only, never journaled |

## Models by name, and failover

`create_model(name, **kwargs)` (`europacore.llm`) builds a model from its name, whichever provider serves it. Keyword arguments go to the provider's constructor, and each provider still needs its extra.

- `claude-*` names that `BedrockConfig` knows, and Bedrock `anthropic.` ids, use Bedrock.
- `gpt-*` and `o*` names use OpenAI's Responses API.
- `openai.` ids use GPT on Bedrock.
- A prefix such as `bedrock/` or `openai/` picks the provider explicitly.
- `register_model_provider(name, pattern, factory)` adds your own provider.

`FallbackLLM(primary, secondary, ...)` sends each call to the first model and, on a transient or recoverable error, to the next:

```python
llm = FallbackLLM(create_model("claude-opus-4-7"), create_model("gpt-6-luna"))
```

- **What fails over.** A malformed request is not retried elsewhere, and a stream fails over only before its first chunk.
- **Cancels.** A cancelled call never fails over.
- **Hung streams.** `first_chunk_timeout=` (seconds) fails a streamed call over when a model sends no first chunk in time; it stops models whose provider honours the request's `CancelToken` (Bedrock and OpenAI do, for non-streaming calls too: they go over the streaming transport). After the harness's `stream_idle_timeout` finds a model stalled before its first chunk, calls start at the next model for `stall_cooldown` seconds (default 60; 0 turns it off).
- **Reported attributes.** It reports the smallest context window of its models. `served_by` names the model that answered the last call.

## Hooks

For a one-off change to what the model or a tool sees, wrap instead of writing a guardrail:

```python
from europacore.agents.guardrails import ToolHooks
from europacore.llm import ModelHooks

llm = ModelHooks(model, before=lambda request: cache.get(request_key(request)), after=store_in_cache)
agent = AgentHarness(llm, tools=..., extra_guardrails=[ToolHooks(before=fill_defaults, after=redact_result)])
```

- **`ModelHooks(llm, before=, after=)`** wraps a model. `before(request)` may edit the request (its messages are a deep copy, so the run's history is untouched) and returns None to call the model, or a response to use instead. `after(request, response)` returns None or a replacement.
- **`ToolHooks(before=, after=)`** is a guardrail. `before(name, args)` may edit `args` and returns None to run the tool, or a value that becomes its result. `after(name, args, result)` returns None or a replacement result.
- **`ToolHooks` failures.** `after` sees the full result before it is truncated and stored, and also a failed call's error result (`after_errors=False` skips those). A hook that raises fails closed by default: `before` blocks the call and `after` withholds the result. `failure_mode="open"` logs the error and carries on instead.

Guardrails remain the way to steer the loop itself: pause, finish, fail or force a final answer.

## Prompt templates

A template is a YAML file at `<dir>/<name>/v<version>/<name>.yaml`:

```yaml
templateEngine: handlebars        # or jinja2
messages:
  - type: system
    text: You are a careful calculator.
  - type: user
    text: "{{question}}"
```

Keyword arguments passed to the harness call (`question=`, `repo_name=`, …) become template variables.

Handlebars templates have these helpers: `list` renders an English list ("a, b, and c"), `json` pretty-prints a value, `eq` is an inline comparison (`{{#if (eq kind 'sql')}}`), `if_eq` is the block form (`{{#if_eq kind 'sql'}}...{{else}}...{{/if_eq}}`), and `object_list` renders a list of objects as a numbered `key: value` outline.

Directories are searched in this order:
1. `PromptRegistry(template_dirs=[...])`
2. `EUROPACORE_TEMPLATE_DIRS` (separated by `os.pathsep`)
3. the library's own templates (`europacore/prompts/templates`, which holds `guardian_approval`)

The first match wins, so an app can override a library template by shipping one with the same name and version.

## Application defaults: `europacore.configure()`

The core never imports application code. Anything that is an application choice is either passed to the constructor or registered once at start-up:

```python
import europacore
from europacore.llm.PromptRegistry import PromptRegistry

europacore.configure(
    prompt_registry_factory=lambda cb: PromptRegistry(cb, template_dirs=["my_app/templates"]),
    compaction_llm_factory=cheap_model,    # summarises dropped turns during compaction
    adjudicator_llm_factory=cheap_model,   # GuardianGuardrail's llm_adjudicator()
    telemetry_attr_prefix="myapp",         # span attrs <prefix>.run.id / <prefix>.turn.id
)
```

| Hook | Default when unset | Per-instance override |
|---|---|---|
| `prompt_registry_factory(callback_handler)` | local-YAML `PromptRegistry` (shared) | `AgentHarness(prompt_registry=)` |
| `token_checker_factory(model, context_window)` | tiktoken `TokenChecker` (cached per model) | `AgentHarness(token_checker=)` |
| `compaction_llm_factory()` | none; compaction uses a structural digest | `AgentHarness(compaction_llm=)` |
| `adjudicator_llm_factory()` | none; raises if needed | `llm_adjudicator(llm=)` |
| `telemetry_attr_prefix` | `"europacore"` | — |
| `persistence.postgres.set_default_connection_factory(f)` | `EUROPACORE_DATABASE_URL` / `DATABASE_URL` | `Postgres*(connection_factory=)` |

## Telemetry

With the `telemetry` extra installed and `PROMPTSTORE_BASE_URL` / `PROMPTSTORE_TELEMETRY_KEY` set (a `pst_…` key, which is scoped to one workspace; `PROMPTSTORE_URL` is still accepted for the URL), a run emits a span tree: `harness.run`, then `loop.iteration`, then `model.call` and `tool.call`. A delegated sub-agent's run nests under its `subagent.spawn` span, so a delegation is one trace. The run span carries the turn's prompt-cache totals as `cache_read_tokens` and `cache_write_tokens`. Everything no-ops when the extra or the variables are absent. The run span carries the run's `session_id`, which PromptStore shows as the trace's Conversation, so a session's runs can be found together. A flow session is one conversation, a single row in PromptStore: a `harness.run` root named after the flow, with ids derived from the session id so every turn (in any process) reaches it; a `loop.iteration` per turn, a `hitl.pause` while the flow waits on the person, and the root closes when the flow completes. Under a turn, a `composition.call` span per node (content: what it was given and the fields it wrote), and under a node the run of any harness it starts (content: the user message in, the structured output or text out). A nested run's own session id goes in `europacore.session.id`, so only the root names the conversation.

`europacore serve` traces every run the same way: install `europacore[server,telemetry]` and set the two variables in the server's environment, or put them in a `.env` file and pass `--env-file .env` (variables already set in the shell win). At startup it logs `PromptStore tracing on (<url>)`, or why tracing is off, and on shutdown it flushes queued spans after draining its runs. An application serving its own FastAPI app can do the same with `telemetry.ensure_configured()`, `telemetry.status()` and `telemetry.flush()`.

A script loads a `.env` itself with `europacore.load_env_file(missing_ok=True)` before its first run; the examples do, so `poetry run python examples/<name>.py` traces too.

A tool that drives its own model can put that call on the trace without a span handle in its signature. The dispatcher publishes the tool's span as an ambient parent for the duration of the call:

```python
from europacore.agents import telemetry

def execute(self, input):
    with telemetry.sub_model_span("query-expansion", model=model_id) as span:
        response = self.llm(request)
        telemetry.record_model_usage(span, response["usage"], model_id)
```

`telemetry.instrument_stream(stream, "summariser", model=model_id)` does the same for a response generator, opening the span on first consumption. Both are inert outside a harness run. The compaction summariser's calls appear the same way, as `compaction-summary` under the run span.

### OpenTelemetry

With the `otel` extra installed, the same run also emits OpenTelemetry spans and metrics, following the GenAI semantic conventions. Configure a `TracerProvider` and `MeterProvider` with an exporter (OTLP, console, ...) in the application to receive them; without one the API's no-op defaults cost nothing.

- **Spans.** `invoke_agent {harness}` per run and per delegated sub-agent, `chat {model}` per model call, `execute_tool {tool}` per tool call, and `loop.iteration` per step. A delegation is one trace. They carry `gen_ai.operation.name`, `gen_ai.agent.name`, `gen_ai.tool.name`, `gen_ai.provider.name`, `gen_ai.request.model` and `gen_ai.usage.*`. Other attributes use the `telemetry_attr_prefix`.
- **Metrics.** `gen_ai.client.token.usage` (by `gen_ai.token.type`) and `gen_ai.client.operation.duration`.
- **Content.** Message content is recorded only with `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`, redacted for credentials and emails.
- **Off switch.** `EUROPACORE_OTEL=0` turns OpenTelemetry off. Promptstore and OpenTelemetry are independent: either, both or neither.

## Security defaults and data retention

Know these before exposing an agent to untrusted input (each trust boundary, its control and its test are listed in [`docs/threat-model.md`](docs/threat-model.md)):

- **Tool authorisation is open by default.** Without a `permission_profile`, a harness may use every capability its tools declare. `PermissionGuardrail` is opt-in and allows anything no rule names. For `exec_command`, pass `exec_allowlist=default_exec_allowlist()` (the spec option `include_exec_defaults` does this): a command then runs unasked only when it is an argv (a list `cmd`, no `shell=True`, not `sh -c`), so no shell reads its words, has no path-named program, and every program in it is on a read-only allowlist (inspection commands, read-only `git`, the common test runners); anything else asks a human or the guardian, and deny rules still apply. Flags that make an allowlisted program run another program or write a chosen path (`find -exec`, `sort -o`, `git --output`) ask too, in any abbreviated or bundled form. Each test runner (`go test`, `pytest`, `cargo test`, `npm`/`pnpm`/`yarn test`) has a flag allowlist instead (`RunnerFlags`): any flag not on it asks (`go test -ldflags`/`-coverprofile`, `pytest --html`, `cargo test -Z`, `npm test -- --outputFile`), and abbreviations are not matched; `uniq`, `tree` and `printf` are not on the list. Every shell line asks, however plain. For a tool that cannot send an argv, `default_exec_allowlist(shell_lines=True)` (spec option `exec_shell_lines`) lets a plain line (literal characters only: no expansion, substitution, glob, redirection, subshell or `#`) run unasked too, which is the weaker check earlier fixes kept patching. The core `exec_command` (`sandbox_tools`) sends an argv. Extend the list with `.extended(commands=[...], runner_flags={prefix: spec.with_flags(...)})` or explicit `("exec:<command>", "allow")` rules. The allowlist decides when to ask; the sandbox profile is the boundary, and the allowlisted test runners execute repository code that only the sandbox contains. Pass the same allowlist to `GuardianGuardrail`.
- **Sandboxing is your choice.** A tool that declares `sandbox_required` still runs in-process, with a warning, unless you pass `require_sandbox_backend=True`. There is no default sandbox provider: `local` runs commands on this host with the worker's environment and is not a security boundary, so choose it explicitly, and use `lambda_microvm` for untrusted code.
- **Telemetry content capture is opt-in.** With `PROMPTSTORE_CAPTURE_CONTENT=true`, model and tool inputs and outputs go to PromptStore after credential and email redaction. Spans and usage are sent either way when the `telemetry` extra is configured.
- **Persisted state holds conversations.** Checkpoints and journal snapshots store full message histories (credentials redacted). Nothing deletes them unless you do: schedule `europacore.persistence.retention.purge_older_than(days)`, which deletes finished and crashed (lease long lapsed) sessions and finished autonomous graphs, and keeps paused sessions. Spooled images and large tool results live in your object store: reclaim them with a bucket lifecycle rule or `purge_spooled_objects(store, prefix, days, keep=...)`, keeping the keys paused sessions reference. Results spooled through `ToolResultSpool` are stored with credentials redacted; `ImageSpool` resolves only handles under its own prefix.
- **Tool credentials never enter the conversation.** A token goes from the provider to the encrypted store and from there into one call's context; it is not in the arguments, the history, the journal or telemetry, and an exact echo of it in the tool's output is redacted. The OAuth callback is unauthenticated by design: its single-use, expiring `state` is the authority (see [Tool credentials](#tool-credentials-europacorecredentials)).
- **Isolation is by owner.** Postgres checkpoints, journal reads and saves are scoped to the configured `workspace_id`/`user_id`; in-memory stores are keyed by session id only and are meant for development. Workspace-only scoping (`require_user_match=False`) requires a `workspace_id` and matches only rows in that workspace: it no longer loads or overwrites a session saved with no workspace.

## Environment variables

Tuning knobs an operator can set without a deploy. Constructor arguments always win over them. The unprefixed names these had before (`HARNESS_COMPACTION_THRESHOLD`, `HARNESS_MAX_TOTAL_TOKENS`, `BEDROCK_CONTEXT_WINDOW`, `OPENAI_CONTEXT_WINDOW`, `BEDROCK_OPENAI_CONTEXT_WINDOW`, `LONG_CONTEXT_PRICING_ENABLED`) are still read, with a `DeprecationWarning` once, until 0.3; the `EUROPACORE_` name wins when both are set.

| Variable | Effect |
|---|---|
| `EUROPACORE_COMPACTION_THRESHOLD` | Compaction trigger as a fraction of the context window (default 0.65). The harness clamps it to the headroom one more step needs, so a wide `max_output_tokens` or `tool_result_max_chars` can lower it. |
| `EUROPACORE_MAX_TOTAL_TOKENS` | Cumulative run-token budget (default `max(500_000, 2.5 x context_window)`). |
| `EUROPACORE_BEDROCK_CONTEXT_WINDOW` / `EUROPACORE_OPENAI_CONTEXT_WINDOW` / `EUROPACORE_BEDROCK_OPENAI_CONTEXT_WINDOW` | Override the context window `europacore.llm.model_specs.model_context_window` resolves from the bundled catalog or litellm for a provider. |
| `LITELLM_LOCAL_MODEL_COST_MAP` | Defaulted to `True` before europacore imports litellm, so litellm reads its bundled price map rather than fetching one. Set it to the empty string to fetch the remote map. |
| `EUROPACORE_LONG_CONTEXT_PRICING_ENABLED=true` | Lift the cap that keeps a model's window below its long-context price cliff (e.g. 272k on `gpt-6-luna`), accepting the higher rate above it. |
| `BEDROCK_MAX_POOL_CONNECTIONS` | HTTP connection pool for the Bedrock clients (default botocore's 10). Raise it to match the thread count of parallel tool dispatch or batch embedding, or botocore churns connections past the pool. |
| `EUROPACORE_TEMPLATE_DIRS` | Extra prompt-template directories (see above). |
| `EUROPACORE_DATABASE_URL` / `DATABASE_URL` | Default Postgres connection (see below). |
| `EUROPACORE_DB_CONNECT_TIMEOUT_SECONDS` / `EUROPACORE_DB_STATEMENT_TIMEOUT_MS` | Timeouts for that default connection (10 s / 30 000 ms; 0 disables the statement timeout). |
| `EUROPACORE_OVERLAP_TOOL_DISPATCH=true` | Default for `overlap_tool_dispatch` (start read-only tools while the model is still streaming). A legacy name is still read (see `docs/migration.md`). |
| `EUROPACORE_OTEL=0` | Turn OpenTelemetry tracing off even when `opentelemetry` is installed (see Telemetry). |
| `PROMPTSTORE_BASE_URL`, `PROMPTSTORE_TELEMETRY_KEY` | Where PromptStore telemetry goes, and its `pst_…` key (see Telemetry). |
| `PROMPTSTORE_CAPTURE_CONTENT=true` | Send model and tool inputs and outputs to PromptStore telemetry (redacted for credentials and emails). Off by default. |
| `SANDBOX_PROVIDER` | `lambda_microvm` (isolated) or `local` (subprocess on this host, not a security boundary). Required: unset or any other value raises. |
| `SANDBOX_AGENT_TOKEN`, `SANDBOX_MICROVM_REGION`, `SANDBOX_MICROVM_IMAGE_ARN`, `SANDBOX_MICROVM_EXECUTION_ROLE_ARN` | MicroVM sandbox settings (region falls back to `AWS_REGION`, then `AWS_DEFAULT_REGION`). Set `SANDBOX_AGENT_TOKEN` on both the worker and the VM: the in-VM agent refuses every request without one. |
| `SANDBOX_AGENT_INSECURE_NO_AUTH=1` | Run the in-VM agent with no bearer check (local development only). |
| `AWS_DEFAULT_REGION`, `AWS_REGION` | Region for the Bedrock models when none is passed (`AWS_DEFAULT_REGION` first, default `us-east-1`). |
| `AWS_BEARER_TOKEN_BEDROCK` / `BEDROCK_API_KEY`, `BEDROCK_OPENAI_BASE_URL` | Credentials and endpoint for `BedrockOpenAIModel` (otherwise minted from AWS credentials). |
| `OPENAI_API_KEY`, `OPENAI_API_URL`, `OPENAI_RESPONSES_API_URL`, `OPENAI_PROJECT`, `OPENAI_ORG_ID`, `OPENAI_FORCE_PROJECT_HEADER` | OpenAI credentials and endpoints for `OpenAIModel`. |
| `OPENAI_CONNECT_TIMEOUT_SECONDS` / `OPENAI_READ_TIMEOUT_SECONDS` | `OpenAIModel` HTTP timeouts (10 s / 900 s). |
| `OPENAI_PROMPT_CACHING_ENABLED` | Toggle OpenAI prompt-cache keys on requests. |
| `EUROPACORE_CREDENTIALS_KEY` | Fernet key(s) encrypting stored tool credentials; comma-separated to rotate (the first encrypts). `PostgresCredentialStore` refuses to start without one unless `allow_ephemeral_key=True`. |

## Durable state (Postgres)

Apply the schema on every deploy. It is idempotent, has no foreign keys to application tables, and records its revision in `europacore_schema_version`:

```python
from europacore.persistence import apply_schema
version = apply_schema(connection_factory)   # == europacore.persistence.SCHEMA_VERSION
```

`schema_sql()` returns the raw DDL if you run migrations with your own tool.

Then provide connections. A connection factory is a zero-argument callable that returns a psycopg 3 connection with `row_factory=dict_row`, used as `with factory() as conn`:

```python
from europacore.agents import PostgresHarnessCheckpointer
from europacore.persistence.postgres import set_default_connection_factory

set_default_connection_factory(my_app.db.get_database_connection)
checkpointer = PostgresHarnessCheckpointer(workspace_id=ws, user_id=user)
```

Resuming is owner-scoped. A configured `user_id` must match the row's owner. `user_id=None` is a system context that may resume anything. `require_user_match=False` falls back to workspace-only scoping.

### Human-in-the-loop

A guardrail can pause a run; for example, `RequiresApprovalGuardrail(tools=["send_email"])`. When that happens:

1. The harness checkpoints `status="paused"`.
2. It returns a `HarnessResult` with `paused=True`.
3. You continue the run with `agent.resume(session_id, approve=True | False, instruction=..., pending_tool_call_id=result.pause["pending_tool_call_id"])`.

Always pass `pending_tool_call_id`. It binds the decision to the pause it answers: if the session is on another pause by then (a retried request or a double click delivered the same approval twice), `resume` raises `PauseMismatchError` and changes nothing. Without it, a duplicated approval approves the next action, which no human has seen. It is optional in 0.1 and required from 0.2; an HTTP or websocket resume endpoint should require it from the client now, since retries and double submits are exactly what it guards against. Every pause carries the id. For a gated call it is that call's id. For a delegated child's approval (`kind="subagent_approval"`) it is the child's gated call, and the parent's delegate call is in `delegate_tool_call_id`. A pause with no gated call (`llm_recoverable`, `fork_seed`, `unknown_tool_outcome`) gets a generated `pause-…` id.

A rejected call is answered with `{"approval": "rejected", "executed": false, "reason": ..., "next_step": ...}`, so the model reports it as declined rather than done.

### Shutdown

A durable journal (Postgres) leases each open run to its process; the lease lasts `max(120 s, 4 × lease_heartbeat_interval_seconds)` and is renewed in the background. A renewal that fails transiently is retried until the lease would lapse, and one that hangs counts as lost at that point too. A process that stops mid-run (SIGTERM, a deploy, a crash) does not release its lease. A worker on the same host takes the session over at once when the holder is provably gone (its pid no longer exists, or it is an earlier incarnation of the worker's own pid: owner ids carry a per-process nonce). A worker on any host does the same when the holder's journal had a liveness lock: pass `liveness=PostgresProcessLiveness(dsn)` (`europacore.agents.harness_journal_liveness`) to `PostgresHarnessCheckpointer` or `PostgresHarnessJournal`. Each process then holds one Postgres advisory lock on a dedicated connection. Postgres releases it when the process dies: at once on a crash, and within about 25 s of TCP keepalives when the host is lost. A worker that finds the session taken probes that lock. The DSN must reach Postgres directly, or through a pooler in session mode, because a transaction-mode pooler releases the lock behind the process's back. A holder judged dead while still alive (its liveness connection dropped) is fenced: its next write raises `RunLeaseError`. Otherwise the session is unavailable until the lease expires (up to the TTL above): its next `stream` or `resume` raises `RunLeaseError`. Then the run is recovered. A non-idempotent tool that was in flight is asked whether its effect landed, if it overrides `ToolAgent.reconcile(input, context)`. If the effect landed, it returns the call's result; if it did not, it returns None, and the call is treated as never run; if it cannot tell, it raises. Only a call that no tool can settle pauses the session as `unknown_tool_outcome` instead of running again. Every call's context carries an `_idempotency_key` (`session_id:tool_call_id`), the same on every attempt at that call. A tool that passes it to an API that de-duplicates by key (an `Idempotency-Key` header) can declare itself idempotent, or ask the API in `reconcile`. For effects written to the harness's own Postgres, `europacore.agents.tool_effects` makes the answer certain. The tool records its result with `record_tool_effect(conn, context, result)` in the transaction that makes the effect, and the `TransactionalEffects` mixin's `reconcile` reads it back. Effect and record commit together, so the call runs exactly once. To shut down cleanly, stop taking new turns and let open runs finish, or cancel them through their `cancel_event`, before the process exits.

With a journal, the checkpointer row is rewritten only at a run's first save, a pause, a final save, a compaction and every 16th save; in between the journal's deltas hold the state, and loading takes the newer of the two. So a session whose row says `running` will not load while the journal cannot be read: that row may be several saves behind, and resuming from it could run a tool again.

### Live steering

`agent.steer(text, command_id=None)` queues a user message that is injected, framed as a mid-run interruption, before the next model call. `command_id` is an idempotency key: the same steer delivered twice (a live call plus a durable queue, say) lands once, and the applied keys are checkpointed in the run metadata for the rest of the turn. Without a `command_id` the text itself is the key.

A steer belongs to the run that is live when it arrives. `steer()` returns `False` when no run is live, so send a normal turn instead. A steer the run ended before injecting comes back on `HarnessResult.undelivered_steers`; it is never carried into the next run.

## HTTP server (`europacore.server`)

The `server` extra serves harness sessions over HTTP with resumable Server-Sent Events. The protocol is in [docs/server-api.md](docs/server-api.md) and the OpenAPI document in `docs/server-openapi.json`. [docs/server-settings.md](docs/server-settings.md) covers running one: flags, authentication, persistence, workspaces, and the coding server's `coder` and `claude-code` agents. Register a factory per agent; it is called once per session and must give the harness a checkpointer and journal scoped to the caller:

```python
from europacore.server import AgentRegistry, StaticTokenAuthenticator, create_app, default_persistence

def coder(ctx):                                   # ctx: SessionContext(session_id, principal, model, metadata)
    stores = default_persistence(ctx)             # in-memory, or Postgres when EUROPACORE_DATABASE_URL is set
    return AgentHarness(create_model(ctx.model or "claude-sonnet-4-5"), tools=[...], system_prompt="...",
                        checkpointer=stores.checkpointer, journal=stores.journal)

registry = AgentRegistry()
registry.register("coder", coder, models=["claude-sonnet-4-5", "claude-haiku-4-5"], default_model="claude-sonnet-4-5")
app = create_app(registry, authenticator=StaticTokenAuthenticator(token))
```

or `europacore serve myapp.agents:registry --port 0`, which prints a `{"event": "ready", "url": ..., "token_file": ...}` line on stdout and writes a generated token to a 0600 file. `examples/server_agents.py` is an offline registry to try it with. `examples/server_bedrock_agents.py` serves a Bedrock Claude agent with tools, planning and an approval pause (needs the `bedrock` extra and AWS credentials).

- **Runs are resources.** A run executes in a server-owned task; closing the connection does not cancel it. `POST /v1/runs/{id}/cancel` and `/steer` act on it, and `if_busy` (`reject`, `steer`, `queue`) decides what a second turn does while one is active.
- **Resumable streams.** Every event has a per-session `seq`; reconnect with `Last-Event-ID` to replay what you missed. Token deltas are best-effort; the step's `assistant-message` always carries the full text.
- **Identity from the token.** An `Authenticator` returns the `Principal(workspace_id, user_id)` that owns sessions; another principal's session answers 404.
- **Decisions are bound to their pause.** `POST /v1/sessions/{id}/resume` requires `pending_tool_call_id`; a stale one gets 409 `pause_mismatch` with the current pause.
- **Tool credentials.** `create_app(..., credentials=service)` puts each principal's credentials on `SessionContext.credentials` (pass it to `AgentHarness(credentials=)`) and serves `/v1/credentials` and the OAuth callback `/v1/oauth/callback`, which resumes the paused run itself.
- **Session index.** Checkpointers cannot list sessions, so the server keeps an index and its event log: `InMemorySessionIndex`, or `PostgresSessionIndex` (the `server_sessions` tables, schema version 6).
- **Claude Code sessions.** With the `claude-code` extra, `europacore serve europacore.coding:agents` also serves `claude-code`: Claude Code running its own agent loop (the `claude` CLI, through the Claude Agent SDK) in the same directories as `coder`. Its sessions stream, pause for approvals, steer, cancel and fork like any other agent's, so a client switches between both kinds without knowing which is which. See [docs/claude-code.md](docs/claude-code.md).

## Tool credentials (`europacore.credentials`)

A tool says which credential it needs; the harness finds it before each call, and asks the person for it when there is none. The token never passes through the model, the conversation, the pause or `resume`. [docs/tool-oauth.md](docs/tool-oauth.md) walks through the OAuth flow end to end.

```python
from europacore.credentials import (
    CredentialService, OAuth2, PostgresCredentialStore, get_credential, github_provider,
)

@function_tool(retry_safety="read_only", auth=OAuth2("github", scopes=("repo",)))
def list_prs(repo: str, context: dict) -> list:
    """List open pull requests."""
    token = get_credential(context, "github").token     # from the call's context, never an argument
    ...

credentials = CredentialService(
    store=PostgresCredentialStore(),                    # encrypted with EUROPACORE_CREDENTIALS_KEY
    providers=[github_provider(client_id, client_secret, scopes=("repo",))],
    redirect_uri="https://app.example.com/v1/oauth/callback",
)
agent = AgentHarness(llm, tools=[list_prs], system_prompt="...", checkpointer=checkpointer,
                     credentials=credentials.for_owner(workspace_id, user_id))
```

**Requirements.** A tool declares one of three, on `ToolAgent.auth` or `function_tool(auth=)`. Each names a provider the service is configured with, and a mismatch fails at construction.
- `OAuth2(provider, scopes=())`: a person connects their account.
- `ApiKey(provider)`: a person enters a key once.
- `ServiceCredential(provider)`: the application's own, from `ServiceProvider(fetch=)`; it never asks anyone.

**Ownership is per provider.** `owner="user"` (the default) means each person connects their own account. `owner="workspace"` means one connection serves everyone in the workspace.

**When the credential is missing**, the run pauses with `kind="needs_credential"` before the call runs. The pause has a `connect` block: `provider`, `display_name`, `kind`, `owner`, `scopes`, plus `auth_url` for OAuth or `instructions` for an API key. A call that also needs approval pauses for approval first, and to connect second, so nobody is asked to connect an account for an action they then reject. `resume(approve=True)` means "connected": the gate checks again, and pauses again with a fresh link if the credential is still missing. `approve=False` answers the call as not run. A delegated sub-agent with no credentials of its own acts with its parent's, and its pause reaches the parent with the same `connect` block.

**The OAuth callback** stores the token and resumes the run:
- **`europacore.server`.** `create_app(..., credentials=service)` serves `/v1/oauth/callback` (docs/server-api.md §3.6).
- **Your own route.** Call the framework-free helper:

  ```python
  @app.get("/oauth/callback")
  def oauth_callback(state: str, code: str | None = None, error: str | None = None):
      result = credentials.complete_oauth(state, code=code, error=error)
      if result.resumable:   # connected, or refused at the provider
          start_in_background(harness_for(result.initiator).resume, result.session_id, approve=result.ok,
                              pending_tool_call_id=result.pending_tool_call_id)
      return HTMLResponse(callback_page(result), headers=PAGE_HEADERS)
  ```

  The single-use, ten-minute `state` is the only authority the callback needs: the provider's redirect carries no login. Flows use PKCE.

For an API key, store what the person entered with `credentials.put_api_key(workspace_id, user_id, provider, key)` (the server: `PUT /v1/credentials/{provider}`), then resume.

**At call time.**
- An expired OAuth token is refreshed first, one refresh at a time per owner and provider.
- If the provider refuses the refresh token, the credential is forgotten and the person is asked to connect again. A transient failure keeps it.
- A tool that finds its credential rejected raises `CredentialRejected(provider)`. The harness forgets the credential and tells the model the call did not run, and the next call asks to connect.
- An exact echo of the token in the tool's result or error reaches the model and the history as `[redacted]`.

**Storage.** `PostgresCredentialStore` (schema version 8) encrypts secrets with `default_encryptor()`. That is Fernet, from the `credentials` extra, keyed by `EUROPACORE_CREDENTIALS_KEY`. Several comma-separated keys rotate: the first encrypts and every one decrypts. Generate a key with `FernetEncryptor.new_key()`. Without a key the store refuses to start, unless `allow_ephemeral_key=True`; that per-process key loses every credential on restart, so use it in development only. Pass `encryptor=` to use a KMS. `InMemoryCredentialStore` is for tests. `purge_older_than` deletes expired flows; connected credentials stay until the person disconnects.

## Long-term memory (`europacore.memory`)

A `MemoryService` keeps short texts per user and finds the ones relevant to a query. `PostgresMemoryService(workspace_id)` uses Postgres full-text search over the `agent_memories` table (schema version 5), so it needs no database extension. `InMemoryMemoryService` is for development.

```python
from europacore.agents.tools.memory_tools import MemoryPreloadGuardrail, memory_tools
from europacore.memory.postgres import PostgresMemoryService

memory = PostgresMemoryService(workspace_id=ws)
agent = AgentHarness(llm, tools=memory_tools(memory, user, writable=True), system_prompt="...",
                     extra_guardrails=[MemoryPreloadGuardrail(memory, user)])
...
memory.add_session(user, session_id, checkpointer.load(session_id).messages)   # when a session ends
```

- **Tools.** `load_memory` lets the model search, and `save_memory` (with `writable=True`) lets it store a fact.
- **Preload.** `MemoryPreloadGuardrail` adds the memories matching the user's latest message to that turn.
- **Fencing.** Memories reach the model fenced as background data, never as instructions.
- **Scope.** Every call names its `user_id`, and a search returns only that user's memories within the workspace.
- **Deletion.** `forget(user_id)` deletes one or all of a user's memories.

## Autonomous runtime (`europacore.autonomous`)

A persisted graph of agent runs: a root run plus researcher, coder, reviewer and verifier children.

- **Graph store:** `AutonomousRunStore`, in-memory or Postgres.
- **Agent tools:** `autonomous.tools.build_collaboration_tools(coordinator, run_id)` gives an agent `spawn_agent`, `send_message`, `wait_agent`, `interrupt_agent`, `list_agents` and `request_user_input`.
- **Budgets:** a spawned child's record carries what was left of its parent's token, time and cost limits (`AgentRunRecord.budget`). Neither coordinator applies it: the application's runner that builds the child's harness must call `run.cap_harness(harness)` (or `ChildBudget.cap`), and on a freshly built harness, since the cap is permanent. Then a child never gets more than its parent had left. (In-process delegation caps the child per call itself.)
- **In-process coordination:** `AutonomousCoordinator`. A child that raises `AutonomousInfrastructureError` (dead object storage, bad credentials) fails the root and cancels its siblings at once, instead of being retried child by child; a Temporal child activity should apply the same rule.
- **Temporal coordination:** `TemporalAutonomousCoordinator`, which needs three things from the app:
  - `client_factory`: an async callable returning a `temporalio` client
  - `steering`: optional; anything with `steer(run_id, message, *, command_id)` and `cancel(run_id)`
  - `root_workflow_id_template`: defaults to `"autonomous-root-{root_run_id}"`

  The app owns the root workflow. That workflow handles the `spawn_child`, `message_child`, `followup_child` and `interrupt_child` signals.

## Flows (`europacore.graph`)

A declarative layer over the harness for conversations and multi-agent work: a flow is a graph of nodes — ask the user, show a message, collect a form, route on a CEL condition, transform state, call a service or an HTTP endpoint, fire a webhook, make one structured model call, run a multi-turn model loop, run an autonomous **harness** (a full `AgentHarness` with tools and guardrails), nest a sub-flow, start work in the background and collect it later, run children side by side (`parallel`) or once per item of a list (`map`) and continue when all are done. Any node can be retried with a backoff (`retry`, `retry_delay`) and bounded per attempt (`time_limit`). Flows are written in FlowScript (`.flow`, see `docs/flowscript.md`) or built in the designer, and compile to a `FlowDefinition`.

```python
from europacore.graph import FlowDefinition, FlowRunner, FlowServices, DefaultLLMFactory, compile_flow

flow = FlowDefinition.model_validate(compile_flow(open("triage.flow").read()))
runner = FlowRunner(flow, services=FlowServices(llm_factory=DefaultLLMFactory(models={"small": "claude-haiku-4-5", "large": "claude-sonnet-4-5"})))
first = await runner.start(session_id="s1")            # runs to the first question
second = await runner.step("s1", user_message="It drips all night")
print(second.status, second.message, second.state.fields)
```

- **One loop, one trace.** A flow run is a journaled, checkpointed run of a session, exactly like a harness run (`ajournaled_run`); every LLM node runs on the one harness in a child session (`<session>::sub::<node>`), capped by what is left of the flow's budget (`FlowLimits`), cancelled with it, and journaled with it. There is no second agent loop: routing is CEL on state, never a model's choice.
- **Pauses.** A node that waits for the user (`ask`, `form`, `loop`) pauses the run (`flow_input`); any node can carry an approval gate (`requires_approval true` or a CEL expression → `flow_approval`, approved, remembered for the session, or rejected onto `on_reject`); a `wait` node pauses until an external event is delivered through a resume (`flow_waiting`); a harness node's own pause — an approval, a credential, a question inside a sub-flow it delegated to — bubbles up as `subagent_approval` and `runner.resume(session_id, pending_tool_call_id=..., approve=...)` re-enters the child. Snapshots are saved after every node, so a run resumes at the node it parked on.
- **A flow as an agent's tool.** `flow_tool(QUOTE_FLOW, name="get_quote", services=svcs, inputs=["product", "qty"])` wraps a flow (a `FlowDefinition`, FlowScript source, or `flow_id=` loaded through `services.flow_resolver`) as a tool with the flow's own name and typed arguments (the chosen `state_schema` fields, plus an optional `message`); `AgentHarness(llm, tools=[search, quote], checkpointer=cp)` lists it like any other. The flow runs as a delegated child: a question, gate or wait inside it pauses the agent's run as `subagent_approval`, and `agent.resume(session_id, approve=True, instruction="blue")` answers it and splices the flow's result into the tool call.
- **Node types.** The built-in set is generic; an application registers its own (`NodeType` with its FlowScript syntax, `NodeTypeRegistry.register`) and both the language and the engine know them. Every application choice — models, service callables, agents, webhooks, HTTP, memory, secrets — is injected through `FlowServices`.
- **Rails.** `FlowLimits` (tokens, cost, seconds, nodes per turn, concurrent agents, depth); harness nodes take `guardrails` specs (`build_guardrail_from_spec`), `required_tools`/`tool_prerequisites`, per-tool `requires_approval`; `validate_flow` lints a definition (dead transitions, unknown CEL roots, pauses in the background or in a fork, mistyped defaults); `strict_state true` makes the declared state types binding (`FlowStateTypeError`).
- **Stores.** Flow definitions with versions (`InMemoryFlowStore`, `SqliteFlowStore`, `PostgresFlowStore`, schema v12) and per-flow secrets (`{secret:NAME}` in tool config; `InMemoryFlowSecretStore`, `SqliteFlowSecretStore`, `PostgresFlowSecretStore`, encrypted with the `credentials` extra when `EUROPACORE_CREDENTIALS_KEY` is set, plaintext with a warning otherwise).
- **Serving.** `register_flow(registry, "quote", flow, services=...)` registers a flow as an agent: sessions, runs, SSE, resume, steer (it reaches the harness node running at that moment) and cancel work unchanged. `--serve-flows` serves every flow saved in the designer the same way, each as the agent named after it ("Tap Triage" is `tap-triage`, so `ec -a tap-triage`), with no code: a saved, renamed or deleted flow shows on the next request, and a session paused mid-flow stays on the version it paused on (`europacore.graph.SavedFlows`, an `AgentRegistry.add_source` source). `europacore serve myapp.agents:registry --graph [myapp.graph:services] --graph-store flows.db --ui` adds the `/v1/graph` routes (flows, FlowScript compile/decompile, node types, test runs, run history, the assistant, secrets; `docs/server-api.md` §3.8) and the designer at `/graph/ui` (the ready line's `ui_url` carries the token in the fragment); `make graph` does this for the coding agents on port 8766 (`GRAPH_PORT=`), beside `make serve` on 8765, with `GRAPH_STORE=` for the flow store and `GRAPH_SERVICES=module:attr` for models. The designer is a Vite app in `ui/graph-designer`, built into the wheel by `scripts/build_ui.py`.
- **Examples.** `examples/graph_flow.py` runs a four-node FlowScript flow offline with a scripted model. `examples/nba_cards.flow` is a complete flow that uses every built-in node: a Next Best Action card studio modelled on cardbuilder, with a brief form, the library loaded in `parallel`, background drafting with deterministic checks and a rewrite, a review loop behind an approval gate, a harness over REST tools that delegates to a sub-agent and a sub-flow (whose own gate bubbles up), a `map` that assembles each Card, a wait for legal sign-off and memory; `examples/nba_cards_flow.py` runs it offline through every pause kind and serves it as the agent `nba_cards` (`europacore serve examples.nba_cards_flow:registry --graph examples.nba_cards_flow:graph --ui`). `make examples` lists the servable examples and serves the one you pick on `MODEL` (default `gpt-6.1-sol`). `examples/table_booking.flow` is the small one to chat with: a bistro concierge (a `say` with quick replies, a `loop` that collects the booking, a `call` routed on the diary's answer, an approval gate before anything is booked); `examples/table_booking_flow.py` runs it offline on a stand-in model and serves it for `ec` or the designer (`europacore serve examples.table_booking_flow:registry --graph examples.table_booking_flow:graph --ui`; `EUROPACORE_MODEL` goes live); the runbook is [docs/examples/table-booking.md](docs/examples/table-booking.md). `examples/flow_tool_agent.py` makes a flow with a branch a tool of a plain `AgentHarness` (`flow_tool`): a refund flow routes small amounts straight to payments and larger ones through an approval gate, which pauses the agent as `subagent_approval` until `resume(approve=...)` picks the paid or the `on_reject` branch; it runs offline, and `make examples EXAMPLE=refunds` serves it as the agent `refunds` for ec, with the Refund flow in the designer (`EUROPACORE_MODEL` goes live, else a rule-based stand-in answers). Design notes: `docs/graph.md`.

## Sandboxes (`europacore.sandbox`)

`get_sandbox_provider()` picks the provider from the `SANDBOX_PROVIDER` env var (or `name=`); there is no default:
- `lambda_microvm`: `LambdaMicroVmProvider`, isolated, for untrusted code
- `local`: `LocalSubprocessSandboxProvider`, a subprocess on this host with the worker's environment; not a security boundary

`sandbox_tools(sandbox)` (`europacore.agents.tools.sandbox_tools`) gives a harness `exec_command`, `read_file`, `write_file` and `edit_file` bound to a sandbox:

```python
from europacore.agents.tools.sandbox_tools import sandbox_tools
from europacore.sandbox import get_sandbox_provider
from europacore.sandbox.LazySandbox import LazySandbox

sandbox = LazySandbox(get_sandbox_provider("lambda_microvm"), clone_url=repo_url)
try:
    agent = AgentHarness(llm, tools=sandbox_tools(sandbox), system_prompt="...", require_sandbox_backend=True)
    ...
finally:
    sandbox.destroy()
```

- **Isolation.** On an isolated sandbox (a MicroVM) the tools satisfy `sandbox_required` themselves. On the `local` provider they do not, so with `require_sandbox_backend=True` they are refused, which is the point: `local` runs commands on this host.
- **Commands.** `exec_command` takes `cmd` as the permission rules read it: an argv list runs as given, and a shell line (a string) is refused unless `sandbox_tools(sandbox, allow_shell=True)`. Argv words are literal, so the exec allowlist and denied flags see exactly what runs.
- **Paths.** Paths are relative to the repository; absolute paths and `..` are refused. `writable=False` leaves out `write_file` and `edit_file`.
- **Restarts.** `keep_sandbox_with_session(sandbox, harness, session_id)` (`europacore.agents.tools.sandbox_session`) records the sandbox's id the moment it is created: in the run's journal at once (so a crash straight after creation still knows it), and in the session's checkpoint at the next save. A run resumed after a restart then reattaches to that sandbox (`SandboxProvider.attach`), with its edits and build output, instead of cloning afresh. Call `destroy()` only once the session is done with the sandbox. A sandbox that is gone (a MicroVM past its lifetime cap) is replaced, and a forked session never inherits one. On the `local` provider only the files survive a restart: services and exec sessions do not. A sandbox whose creation returned but whose record had not reached the journal when the process died is not found again; only the provider's lifetime cap ends it.

`sandbox/microvm_image/` holds the in-VM agent and the base Dockerfile. The `*_ops.py` modules are stdlib-only, so they can be copied into the image unchanged.

## Coding agent (`europacore.coding`)

`build_coding_harness(llm, root)` is an `AgentHarness` set up to work on a checkout or git worktree on this machine: read, search, edit and run commands under the OS sandbox, the project's `AGENTS.md`/`CLAUDE.md` and skills, planning, an `explore` sub-agent, and (with `parallel_tasks=True`) coders in worktrees of their own. `europacore serve europacore.coding:agents` serves it (see `europacore.coding.server` for its environment variables).

**Permission modes.** A session runs in one of Claude Code's permission modes, switchable while it runs (`set_permission_mode(harness, session_id, mode)`, or the server's `permission_mode`):

| Mode | What runs without asking |
|---|---|
| `acceptEdits` (default) | Edits and confined commands; operations that lose work (`git reset --hard`, `rm -r`, pushes) ask |
| `default` | Read-only calls and what a rule allows by name; every other change asks |
| `plan` | Read-only calls only. The agent investigates, then calls `exit_plan_mode` with its plan; approving it switches back and the agent implements |
| `dontAsk` | As `default`, but what would ask is denied: for unattended runs |
| `auto` | As `acceptEdits`; what would ask goes to a guardian model (`auto_mode_llm`, else the configured adjudicator, else the agent's model) before the user |
| `bypassPermissions` | Everything deny rules allow, except widening the OS sandbox |

```python
from europacore.agents import InMemoryHarnessCheckpointer, set_permission_mode
from europacore.coding import build_coding_harness

agent = build_coding_harness(llm, "/src/app", checkpointer=InMemoryHarnessCheckpointer())
set_permission_mode(agent, "s1", "plan")
paused = agent(question="Make the importer resumable", session_id="s1")   # pauses on exit_plan_mode
print(paused.pause["arguments"]["plan"])
done = agent.resume("s1", approve=True, pending_tool_call_id=paused.pause["pending_tool_call_id"])
```

Each result lists what the guardrail turned down (`result.permission_denials`).

**Pull requests.** The agent's commands never reach the user's ssh agent or `gh` login, so it can't push. Its work leaves the machine through `create_pull_request`, which asks first. The tool pushes the committed branch and opens a pull request into the branch the worktree was made from (else the remote's default branch). Calling it again pushes new commits to the open pull request. It publishes with a GitHub App, the same one the fleet uses (`EUROPACORE_GITHUB_APP_ID` and its private key; `docs/fleet.md`, "Publishing"), or with `build_coding_harness(pull_requests=GitHubPublisher(app))`. The App's token covers that one repository and is never given to the agent. Without an App there is no tool.

**A repository set up for Claude Code.** The coding agent reads the same configuration Claude Code does:

- **Instructions:** `CLAUDE.md` (or `AGENTS.md`), `.claude/CLAUDE.md`, `CLAUDE.local.md`, the user's `~/.claude/CLAUDE.md`, with their `@path` imports (four hops; a repository's files import only from inside it), and `.claude/rules/*.md` (those without `paths`).
- **Permissions:** `permissions.allow`, `ask` and `deny` in `.claude/settings.json`, `.claude/settings.local.json` and `~/.claude/settings.json` (`$CLAUDE_CONFIG_DIR`), with Claude Code's rule syntax: `Bash(git push:*)`, `Read(./.env)`, `Edit(src/**)`, `WebFetch(domain:example.com)`, `Agent(name)`, `mcp__server__tool`; also `additionalDirectories` and `defaultMode`. Rules that cannot be expressed are reported.
- **Hooks:** the settings' `hooks` (with `.europacore/hooks.toml`), with Claude Code's protocol: exit codes, `permissionDecision` (`allow`/`ask`/`deny`), `updatedInput`, `additionalContext`, `updatedToolOutput`, `continue`, `systemMessage`, on `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `Stop`, `SessionStart`, `UserPromptSubmit`, `SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact` and `SessionEnd`. Matching hooks run in parallel; `async` hooks report at the next model call; `http` hooks (from your own settings) are POSTed the payload; `$CLAUDE_ENV_FILE` and `transcript_path` work as in Claude Code. A hook written for Claude Code sees Claude Code's tool names (`Bash`, `Edit`…) and input fields.
- **Sub-agents:** `.claude/agents/*.md`, each a sub-agent the coding agent can delegate to, with its own prompt, tools (`tools: Read, Grep, Bash`), model and turn limit, under the agent's permission rules.
- **Slash commands:** `.claude/commands/*.md`; a prompt `/fix 123` runs the command's prompt with its arguments (`$ARGUMENTS`, `$0`…). Your own commands may allow tools for their turn (`allowed-tools`); `` !`git status` `` in a command puts that command's output there.
- **MCP servers:** `.mcp.json` and `~/.claude.json`. A repository's server runs once you enable it (`enabledMcpjsonServers` in your settings, or approving the agent's `connect_mcp_server` "always"); its tools (`mcp__server__tool`) ask before each call until a rule allows them. The server lists them at `GET /v1/sessions/{sid}/mcp`.

The coding tools also read Jupyter notebooks as cells and edit one cell at a time (`notebook_edit`), and `web_fetch` can take a `prompt` that a small model answers from the page (`web_fetch_llm=`, or the server's `EUROPACORE_FAST_MODEL`), so the page itself never reaches the agent's context.

**Trust.** A cloned repository's own settings may narrow what the agent does, not widen it, until you trust the repository: its `deny` and `ask` rules and its hooks (which run under the OS sandbox) always apply, but its `allow` rules, `additionalDirectories` and a widening `defaultMode` are ignored, with a warning (the `claude_config_warnings` event). Your own files (`~/.claude/settings.json`, and a `settings.local.json` git does not track) are trusted, and your own hooks run on the host. Trust a repository with `europacore trust [PATH]` (or `europacore.coding.trust_project(path)`, or `build_coding_harness(trust_project=True)`); it is kept per main checkout in `~/.europacore/approvals/trusted.json`. Leave the user's `~/.claude` out with `build_coding_harness(claude_user_config=False)` (the coding server's `EUROPACORE_CLAUDE_USER_CONFIG=0`), as a server serving other people should.

## Fleet (`europacore.fleet`)

A fleet runs many coding tasks over one repository, each in its own worktree and harness session, and judges each by scorers run on an immutable snapshot of its work. A spec lists the tasks, their dependencies, the profile (an agent in `.claude/agents`) that works on each, the paths it may write, and its scorers (a `command` that must exit 0, the `design` checks, or a model `judge`):

```sh
europacore fleet run tasks.toml --check --live   # validate, one tiny call per model, nothing created
europacore fleet run tasks.toml                  # create and supervise until done, or until it waits on you
europacore fleet status RUN                      # each task, whose turn it is, the exact next commands
europacore fleet answer RUN TASK --approve       # also retry / steer / cancel / resume; all idempotent by --request-id
```

The design, in brief:
- The ledger holds only intent (tasks, attempts, commands, receipts); git holds the code and each session's journal holds what its worker did, and status is derived from them when read.
- One supervisor per run, holding the journal's lease on `fleet::<run>`, applies every command.
- A retry continues the task's session with its scorers' findings, and a crash resumes it in place, so nothing is redone.
- Workers never push or open pull requests. A completed run is published (`integration.publish = "branch" | "pr"`) by the fleet's GitHub App, with a token for that one repository, from a laptop or a server alike.
- Signed webhooks start runs from a template in the repository, then steer, approve and cancel them (`europacore serve --fleet-triggers`): a labelled issue becomes a run, and one started by someone untrusted waits for a `/fleet approve`.

See [docs/fleet.md](docs/fleet.md).

## Design checks (`europacore.design`)

A change may not make a repository's design worse. The checks are deterministic and run base against head on the files a change touched, so a repository's existing debt never fails a change and nothing needs a baseline to start:
- file and function size;
- complexity, counted as ruff's C901 counts it;
- import layers (forbidden edges and leaf packages) and import cycles;
- optional dependencies imported at module top level;
- function bodies copied with their names changed;
- inheritance used to reuse implementation, broad `except`s that swallow errors silently, and undocumented exports;
- *guessing*: code that hedges over a fact it could have looked up (`d.get("userId") or d.get("user_id")`, `*args` it never reads, `getattr` probes, fallback chains).

The rules live in `.europacore/design.toml`. A documented trade-off is listed under `accepted`, with its reason.

```sh
europacore design check --base main   # exit 2 when the change adds a blocking finding: a hook, a CI step or a verify line
europacore design debt                # every finding in the repository as it stands
europacore review --fix --publish     # a whole-repository review, then a fleet run that fixes it and opens a pull request
```

`build_coding_harness(..., design_gate=True)` runs them after each edit and before the agent may finish. A fleet scores with them through `{ kind = "design" }`, and `{ kind = "judge", rubric = "design" }` asks a model for what no check can see. See [docs/design.md](docs/design.md).

## Development

```bash
poetry install --all-extras
poetry run pytest                                          # unit tests (offline)
poetry run python scripts/api_reference.py                 # regenerate docs/api.md after changing the public API
EUROPACORE_INTEGRATION=1 poetry run pytest -m integration  # live Bedrock tests
```

