# New Adapter Implementation Guide

**Package version:** `entr-adapter-core >= 1.0.0`
**Last verified:** 2026-03-13

This guide walks you through creating a new ENTR adapter from scratch. By the end, you will have a running adapter that receives a test document payload and returns a success response.

You will create exactly **9 files**:

| # | File | Purpose |
|---|------|---------|
| 1 | `pyproject.toml` | Package dependencies and build config |
| 2 | `src/handlers/__init__.py` | Handler package marker |
| 3 | `src/handlers/invoice_handler.py` | First handler implementation |
| 4 | `src/main.py` | Entry point and handler manifest |
| 5 | `src/config.py` | Tenant-specific configuration |
| 6 | `deploy/README.md` | Override convention documentation |
| 7 | `docker-compose.override.yml` | Tenant-specific compose overrides |
| 8 | `CLAUDE.md` | AI agent context (slim, adapter-specific) |
| 9 | `tests/test_handlers.py` | Handler unit tests |

> **For Claude Code:** This guide is designed to be followed step-by-step by both human developers and AI agents. Each step produces a verifiable output.

---

## Section 1 — Prerequisites & Overview

### Prerequisites

- Python 3.12+
- `uv` package manager ([install guide](https://docs.astral.sh/uv/getting-started/installation/))
- Docker + Docker Compose
- Entr-Core repository cloned as a sibling directory (`../Entr-Core`)

### How ENTR Adapters Work

ENTR Core processes documents (PDFs, Word, emails) into structured JSON using LLMs. It then sends the JSON to tenant-specific adapters via a `/process` HTTP endpoint. The adapter applies business logic (validation, transformation, ERP integration) and returns a standardized response.

```
Document → Core (preprocessing + LLM extraction) → DocumentPayload → Adapter (handler) → AdapterResponse → Core
```

### End Goal

By the end of this guide you will have a running adapter that receives a test `DocumentPayload` and returns an `AdapterResponse` with `status: "success"`.

---

## Section 2 — Repository Setup

### Step 2.1: Create the repository

```bash
mkdir MessageBird-MyTenant
cd MessageBird-MyTenant
```

Replace `MyTenant` with your tenant name.

### Step 2.2: Initialize git

```bash
git init
```

### Step 2.3: Create `.gitignore`

Create a file named `.gitignore` with the following content:

```
__pycache__/
*.egg-info/
.venv/
dist/
build/
.env
*.pyc
.pytest_cache/
```

### Step 2.4: Create the directory skeleton

```bash
mkdir -p src/handlers deploy tests
touch src/__init__.py src/handlers/__init__.py
```

### Step 2.5: Verify

Your directory tree should look like this:

```
MessageBird-MyTenant/
├── src/
│   ├── __init__.py
│   └── handlers/
│       └── __init__.py
├── deploy/
├── tests/
└── .gitignore
```

---

## Section 3 — Package Installation

### Step 3.1: Create `pyproject.toml`

Create `pyproject.toml` at the repository root:

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "messagebird-mytenant"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "entr-adapter-core>=1.0,<2",
]

[tool.hatch.build.targets.wheel]
packages = ["src"]

[dependency-groups]
dev = [
    "pytest>=9.0.0",
    "pytest-asyncio>=1.0.0",
]
```

Replace `messagebird-mytenant` with your adapter name. Adjust the version specifier if you need a different range (e.g., `>=1.0,<1.1` for patch-only updates).

If your adapter needs connectors, add the extras:

```toml
# For Gmail retrieval:
"entr-adapter-core[gmail]>=1.0,<2",
# For SFTP:
"entr-adapter-core[sftp]>=1.0,<2",
# For all connectors:
"entr-adapter-core[connectors]>=1.0,<2",
```

### Step 3.2: Install dependencies

```bash
uv sync
```

### Step 3.3: Verify

```bash
uv run python -c "from entr_adapter_core import __version__; print(f'entr-adapter-core {__version__}')"
```

**Expected output:** `entr-adapter-core 1.0.0` (or your pinned version)

---

## Section 4 — Handler Scaffolding

A **handler** receives a `DocumentPayload` from Core, applies tenant-specific business logic, and returns an `AdapterResponse`. Each handler maps to an `adapter_identifier` — a string that Core uses to route documents to the correct handler.

### Step 4.1: Create `src/handlers/invoice_handler.py`

```python
import logging

from entr_adapter_core.handlers import BaseHandler
from entr_adapter_core.schemas import DocumentPayload, AdapterResponse, StatusEnum

logger = logging.getLogger(__name__)


class InvoiceHandler(BaseHandler):
    """Processes invoice documents from Core.

    Recommended flow: validate → transform → send → respond.
    See handlers.md in the entr-adapter-core package for patterns.
    """

    async def process(self, payload: DocumentPayload) -> AdapterResponse:
        logger.info("Processing document %s", payload.document_id)

        # 1. Validate
        data = payload.data
        if "invoice_number" not in data:
            return AdapterResponse(
                document_id=payload.document_id,
                status=StatusEnum.FAILED,
                failure_type="validation_error",
                failure_reason="Missing invoice_number field",
            )

        # 2. Transform
        invoice_number = data["invoice_number"]
        logger.info("Invoice number: %s", invoice_number)

        # 3. Send — TODO: replace with actual target system integration
        # result = await self.settings.erp_client.create_invoice(transformed_data)
        logger.info("Simulating send to target system")

        # 4. Respond
        return AdapterResponse(
            document_id=payload.document_id,
            status=StatusEnum.SUCCESS,
            details={"invoice_number": invoice_number},
        )
```

**Handler rules:**
- Use `logging.getLogger(__name__)` — never `print()` (print bypasses the flight recorder)
- Return `AdapterResponse` for business outcomes — never `raise HTTPException`
- Let unexpected exceptions propagate — the framework handles them safely
- Never store request-specific data on `self` — handlers are singletons shared across concurrent requests

### Step 4.2: Verify

```bash
uv run python -c "from src.handlers.invoice_handler import InvoiceHandler; print('Handler OK')"
```

**Expected output:** `Handler OK`

---

## Section 5 — Handler Registration in main.py

`main.py` is the adapter entry point and serves as the **handler manifest** — it maps adapter identifiers to handler instances.

### Step 5.1: Create `src/main.py`

```python
from entr_adapter_core import create_adapter_app
from src.handlers.invoice_handler import InvoiceHandler
from src.config import get_settings

settings = get_settings()

app = create_adapter_app(
    handlers={
        "invoice": InvoiceHandler(settings=settings),
    },
    title=f"ENTR Adapter - {settings.TENANT_NAME}",
    settings=settings,
)
```

The `handlers` dict keys are `adapter_identifier` strings that match the Process configuration in Core. The values are handler instances — instantiated once at startup (singletons).

`create_adapter_app()` provides all standard endpoints automatically: `/process`, `/list_documents`, `/download_document`, `/health`.

### Step 5.2: Verify

```bash
uv run python -c "from src.main import app; print([r.path for r in app.routes])"
```

**Expected output:** A list containing `/process`, `/health`, and other standard routes.

---

## Section 6 — Tenant Configuration

### Step 6.1: Create `src/config.py`

```python
from functools import lru_cache
from entr_adapter_core.config import CoreSettings


class TenantSettings(CoreSettings):
    """Tenant-specific configuration.

    Add environment variables for your target systems here.
    All variables are loaded from .env and can be overridden by environment.

    Example:
        ERP_API_URL: str = ""
        ERP_API_KEY: str = ""
    """
    pass


@lru_cache()
def get_settings() -> TenantSettings:
    """Return singleton settings instance."""
    return TenantSettings()
```

`CoreSettings` provides base fields: `TENANT_NAME` (required), `ENV` (default: `"local"`), `IS_TESTING` (default: `True`). Your `TenantSettings` class extends with tenant-specific variables.

### Step 6.2: Create `.env`

Create `.env` at the repository root:

```
# Tenant identity
TENANT_NAME=MyTenant

# Environment (local, staging, production)
ENV=local

# Testing mode
IS_TESTING=true

# Tenant-specific variables (add as needed)
# ERP_API_URL=https://api.example.com
# ERP_API_KEY=your-key-here
```

**Important:** `.env` is in `.gitignore` and must never be committed. It contains secrets.

### Step 6.3: Verify

```bash
uv run python -c "from src.config import get_settings; s = get_settings(); print(f'Tenant: {s.TENANT_NAME}, ENV: {s.ENV}')"
```

**Expected output:** `Tenant: MyTenant, ENV: local`

---

## Section 7 — Deployment Configuration

Default deployment files (Dockerfile, nginx.conf, fluent-bit.conf, docker-compose files) are provided by the `entr-adapter-core` package. You only need to create overrides for tenant-specific needs.

### Step 7.1: Create `deploy/README.md`

```markdown
# Deploy Overrides

Default deployment files are provided by the `entr-adapter-core` package.
To override any default, place a file with the same name in this directory.

The override resolution system picks adapter files over package defaults at build time.

See `defaults.md` in the entr-adapter-core package for the full override decision table.

## Override Examples

- Custom Dockerfile → `deploy/Dockerfile`
- Custom nginx config → `deploy/nginx.conf`
- Custom fluent-bit config → `deploy/fluent-bit.conf`
```

### Step 7.2: Create `docker-compose.override.yml`

Create `docker-compose.override.yml` at the repository root (not in `deploy/`):

```yaml
services:
  adapter:
    env_file:
      - .env
```

This is the compose override that stacks on top of the package defaults. Add tenant-specific services, volumes, or environment variables here as needed. Use **map-style** environment variables for proper merge behavior:

```yaml
# CORRECT — map-style, merges individual keys
services:
  adapter:
    environment:
      MY_CUSTOM_VAR: some-value

# WRONG — list-style, replaces entire env block
services:
  adapter:
    environment:
      - MY_CUSTOM_VAR=some-value
```

### Step 7.3: Override decision table

| What to customize | How to do it |
|-------------------|-------------|
| Environment variables | Compose override with map-style env vars |
| Dockerfile | Place custom `Dockerfile` in `deploy/` |
| Nginx config | Place custom `nginx.conf` in `deploy/` |
| Fluent-bit config | Place custom `fluent-bit.conf` in `deploy/` |
| Compose services/volumes | `docker-compose.override.yml` at repo root |

Most adapters will never need to override defaults. Only do so when you have a specific customization need.

---

## Section 8 — CLAUDE.md

### Step 8.1: Create `CLAUDE.md`

Create `CLAUDE.md` at the repository root:

```markdown
# CLAUDE.md

This adapter uses `entr-adapter-core`. Read the package's co-located
docs (in each module directory) for framework patterns, BaseHandler
contract, schemas, and deployment conventions.

## Tenant: MyTenant

### Business Context
<!-- Describe what this tenant does, what systems they use -->

### Handlers
- `invoice` -> InvoiceHandler: Processes invoice documents

### Target Systems
<!-- List external systems this adapter integrates with -->

### Conventions
<!-- Any tenant-specific coding rules or patterns -->
```

### The three-layer context model

| Layer | Location | Contains |
|-------|----------|----------|
| Package knowledge | `entr-adapter-core` module markdown + docstrings | Framework patterns, handler contract, schemas, deployment |
| Core platform knowledge | Entr-Core CLAUDE.md | Full pipeline, events, how adapters fit the system |
| Adapter-specific knowledge | This file | Tenant business rules, target systems, handler details |

Keep this file slim. Never duplicate framework or Core knowledge — point to the package docs instead.

A full template with all sections is available at `src/entr_adapter_core/defaults/CLAUDE.md.template` in the package. For detailed guidance on what should and should not appear in an adapter CLAUDE.md, see [CLAUDE.md Convention](claude-md-convention.md).

**Verify:** Your CLAUDE.md does not duplicate framework or Core documentation.

---

## Section 9 — Test File

### Step 9.1: Create `tests/test_handlers.py`

```python
import pytest

from entr_adapter_core.schemas import DocumentPayload, StatusEnum
from src.handlers.invoice_handler import InvoiceHandler
from src.config import get_settings


@pytest.fixture
def handler():
    return InvoiceHandler(settings=get_settings())


@pytest.fixture
def sample_payload():
    return DocumentPayload(
        adapter_identifier="invoice",
        document_id=1,
        data={"invoice_number": "INV-001", "total": 100.00},
    )


@pytest.mark.asyncio
async def test_invoice_handler_returns_success(handler, sample_payload):
    response = await handler.process(sample_payload)
    assert response.status == StatusEnum.SUCCESS


@pytest.mark.asyncio
async def test_invoice_handler_fails_on_missing_field(handler):
    payload = DocumentPayload(
        adapter_identifier="invoice",
        document_id=2,
        data={"total": 50.00},  # missing invoice_number
    )
    response = await handler.process(payload)
    assert response.status == StatusEnum.FAILED
    assert "invoice_number" in response.failure_reason
```

### Step 9.2: Verify

```bash
uv run pytest tests/ -v
```

**Expected output:** 2 tests pass.

**Total files created: 9** (compliant with the < 10 file target).

---

## Section 10 — Local Dev Environment Startup

### Step 10.1: Verify Entr-Core is a sibling directory

```bash
ls ../Entr-Core/docker-compose.yml
```

If this fails, clone Entr-Core as a sibling: `git clone <core-repo-url> ../Entr-Core`

### Step 10.2: Configure the ENTR CLI

```bash
entr-cli config set-active-tenant MessageBird-MyTenant
```

### Step 10.3: Start the local dev environment

```bash
entr-cli dev start
```

This starts Core (backend, frontend, database) and the adapter with live reload.

### Step 10.4: Verify all services

| Service | URL | Expected |
|---------|-----|----------|
| Core backend | `http://localhost:8000/docs` | FastAPI Swagger UI |
| Core frontend | `http://localhost:3000` | ENTR web interface |
| Adapter health | `http://localhost:8080/health` | `{"status": "ok", "handlers": ["invoice"]}` |

### Step 10.5: Live reload

Editing files in `src/` will automatically restart the adapter without rebuilding containers.

### Troubleshooting

| Problem | Solution |
|---------|----------|
| Adapter fails to start | Check `.env` is present and has `TENANT_NAME` |
| Core not reachable | Verify Entr-Core is at `../Entr-Core` |
| Health shows no handlers | Check `src/main.py` handler registration |
| Port conflicts | Check for other services on ports 8000, 3000, 8080 |

---

## Section 11 — First Test Document Processing

### Step 11.1: Send a test payload

```bash
curl -X POST http://localhost:8080/process \
  -H "Content-Type: application/json" \
  -d '{
    "adapter_identifier": "invoice",
    "document_id": 999,
    "data": {"invoice_number": "TEST-001", "total": 42.00}
  }'
```

### Step 11.2: Verify the response

The response should be a valid `AdapterResponse` with:
- `"status": "success"`
- `"document_id": 999`
- `"execution_logs"` containing the handler's log messages (captured by the flight recorder)

### Step 11.3: Check adapter logs

The adapter container logs should show the flight recorder capturing your handler's `logger.info()` calls.

### Step 11.4: Full stack test (optional)

1. Open the Core UI at `http://localhost:3000`
2. Configure a Process with `adapter_identifier: "invoice"` pointing to the adapter
3. Upload a document and process it
4. Verify the adapter receives the payload and returns success

Your adapter is operational.

---

## Section 12 — Porting from an Existing Adapter

When building a new adapter that shares business logic with an existing one (same ERP, same document types), copy files rather than rewriting them.

### File copying rules for AI agents

- **Files that need NO changes** (no import updates, no renames): Use `cp` or `Bash` to copy directly from the source adapter. Do NOT rewrite them with the Write tool — this wastes tokens and risks introducing subtle differences.
- **Files that need modifications** (import updates, class renames, removed dependencies): Copy first with `cp`, then use Edit to make targeted changes.
- **Binary/data files** (CSVs, images, models): Always use `cp`.

### Example workflow

```bash
# 1. Copy files that need no changes
cp -r ../MessageBird-ExistingTenant/src/matching/ src/matching/
cp ../MessageBird-ExistingTenant/src/tenant_schemas.py src/tenant_schemas.py
cp ../MessageBird-ExistingTenant/src/utils/plant_code_lookup.py src/utils/plant_code_lookup.py

# 2. Copy files that need import updates, then edit
cp ../MessageBird-ExistingTenant/src/handlers/existing_handler.py src/handlers/new_handler.py
# Then use Edit to update class names and imports
```

### What typically changes when porting

| File type | Copy as-is? | What changes |
|-----------|-------------|-------------|
| Utility modules (matching, lookup, helpers) | Yes | Nothing |
| Data files (CSVs, configs) | Yes | Nothing |
| Connectors (ERP, email, FTP) | Usually | Import paths if moving between `app/` and `src/` layouts |
| Handlers | No | Class name, imports, possibly mailbox/credential references |
| `config.py` | No | Different tenant-specific settings |
| `main.py` | No | Different handler manifest |

---

## Section 13 — Next Steps

### Adding more handlers

1. Create a new file in `src/handlers/` (e.g., `credit_note_handler.py`)
2. Extend `BaseHandler` and implement `process()`
3. Register in `src/main.py`: add to the `handlers` dict with a new `adapter_identifier`

### Adding document retrieval

If your adapter needs to pull documents from email, FTP, or other sources:

1. See `connectors.md` in the package for pre-built connectors (Gmail, Exchange, SFTP, FTP)
2. Inject a `RetrievalHandler` into your `BaseHandler` — see `handlers.md`
3. Core's scheduler will call `/list_documents` and `/download_document` automatically

### Customizing deployment

See `defaults.md` in the package for the override decision table and conventions.

### Understanding the full contract

See `schemas.md` in the package for `DocumentPayload` and `AdapterResponse` details.

### Setting up CI/CD

See the deployment pipeline documentation in Entr-Core for `build.yml` and `deploy.yml` workflow setup.

### Version management

The `entr-adapter-core` version is controlled by the version specifier in `pyproject.toml` (e.g., `>=1.0,<2`). Run `uv lock` to pick up new versions. No custom auto-update mechanism is needed.

For the Core platform version (Docker images), create `adapter-version-policy.yml` at the repo root:

```yaml
core_platform: v2.5    # auto-update Core Docker images to latest 2.5.x
```

See `VERSIONING.md` in the package for the semantic versioning policy.

---

## Appendix — Overridable Files Reference

| Default File | Default Behavior | When to Override |
|-------------|-----------------|-----------------|
| `Dockerfile` | Python 3.12 slim, uv install, uvicorn on port 8096 | Custom system packages, different base image |
| `nginx.conf` | Routes Core traffic (nextapp + backend), 50M upload, SSE support | Custom routing, SSL termination, rate limiting |
| `docker-compose.dev.yml` | Core + adapter with live reload | Never override directly — use `docker-compose.override.yml` |
| `docker-compose.prod.yml` | Variable substitution, restart policies, health checks | Never override directly — use `docker-compose.override.yml` |
| `fluent-bit.conf` | CloudWatch Logs via fluentd forward, tenant-specific stream | Custom log destinations, additional parsers |

Most adapters use all defaults without overriding.
