Imported from TekkenSteve/Kardcraft (
backend/agent-server/AGENTS.md). Install upstream withnpx skills add TekkenSteve/Kardcraft --skill agent-server. Copyright stays with the author.
AGENTS.md
Context for AI coding agents working with this repository.
What this is
A template for a self-hosted Agent Protocol server: FastAPI + LangGraph + PostgreSQL
(+ optional Redis), structured with clean-architecture layering. It is a restructuring of
the aegra feature set into a go-clean-template-style layout.
There is no CLI and no code generation — the Makefile and docker compose drive everything,
and all wiring is explicit Python in src/agent_server/app/main.py.
Commands — drive everything through the Makefile
| Task | Use this | Not this |
|---|---|---|
| Install dependencies | make install |
pip install |
| Start deps only (Postgres + Redis) | make deps |
docker compose up |
| Dev stack in Docker (no Redis, hot reload) | make dev |
hand-rolled compose flags |
| Full stack in Docker (Redis workers) | make up |
— |
| Server locally with hot reload | make run (after make deps) |
bare uvicorn |
| Unit + integration tests | make test |
pytest tests/ — e2e tests need a running server |
| Lint / format / types | make lint / make format / make type-check |
direct tool runs |
| CodeQL (same engine + suites as CI) | make codeql → .codeql/codeql-report.md |
pushing to GitHub and reading the Security tab |
| Regenerate OpenAPI spec (after adding endpoints) | make openapi → commit the result |
letting the CI bot open a drift PR |
| One command before committing | make pre-commit (format + lint + types + arch + security + spec drift + tests) |
running each gate by hand |
| New migration | make migrate-create MSG="..." |
alembic revision — env.py wires the DB URL from settings |
| Apply migrations | make migrate-up |
alembic upgrade |
| E2E (dev / prod / auth) | make e2e-dev / make e2e-prod / make e2e-auth |
running pytest against a hand-started server |
Notes:
make testcoverstests/unit+tests/integration+tests/graphs+tests/kardcraft+tests/hub+tests/ml. One integration test (test_assistant_large_config_db.py) needs a real Postgres with migrations applied — it self-skips when theassistanttable is missing, somake testis safe withoutmake deps.tyIS blocking and the codebase passes with ZERO config overrides: boundary code is typed honestly (sse_starlette's ownContentunion;dictinternally,RunnableConfigcasts only at langgraph's boundary; one annotated# ty: ignore[unknown-argument]for deliberate LangGraph version tolerance). The extra warn-level rules from ty's "coming from mypy/pyright" guide are on; warnings stay visible but non-blocking (--exit-zero-on-warning).banditis non-blocking.ruffIS blocking.- Never claim a change is done without
make pre-commitpassing (it runs format, lint, type-check, arch-check, security, openapi drift check and the full test suite).
Layering
app ──→ controller/http ──→ usecase ──→ repo ──→ domain
│ │
└──→ auth ───┘ infra ← any layer may use it; it knows no domain
Inner layers never import outer ones. Wiring happens once, in app/main.py (create_app +
lifespan): middleware stack, routers, exception handlers, DB/broker/executor lifecycle.
| The code knows about… | It belongs in | Examples |
|---|---|---|
| nothing but the Agent Protocol domain | domain/ |
Assistant, Thread, Run, User, enums, AgentProtocolError |
| SQL, tables, pools, alembic, graph module loading | repo/ |
database.py (dual pools), orm.py, graphs/langgraph_service.py |
| a business rule or run/streaming orchestration | usecase/ |
assistant_service.py, execution/, streaming/, cron/ |
| an HTTP status, request/response shape, ASGI middleware | controller/http/ |
routers/*.py, middleware/ |
| authentication/authorization policy | auth/ |
handlers.py (@auth.on registry), middleware.py, enforcement.py, rate_limit.py |
| infrastructure with no domain knowledge | infra/ |
redis.py, sse.py, observability/, logging.py |
env vars and langgraph.json parsing |
config/ |
settings.py, graph_config.py |
| lifespan, router registration, app assembly | app/ |
main.py, route_merger.py, app_loader.py |
Layout rules:
- One file per concern per layer, named after it (
assistant_service.py, notservices.py). - Database access goes through
repo/database.py(db_manager): SQLAlchemy pool (asyncpg) for metadata tables, LangGraph pool (psycopg) for checkpoints/store. Never cross the drivers —settings.db.database_urlis SQLAlchemy-only,database_url_syncis psycopg-only. - Schema changes go through alembic migrations, never raw DDL from app code.
Framework migrations live in
src/agent_server/migrations/(framework schema, versioned with the framework); business-table migrations live with their domain package — never mix the two chains. infra/must not import fromdomain/,usecase/,repo/orcontroller/.
The two config files
.env— every runtime knob, parsed by typed groups inconfig/settings.py(settings.app,settings.db,settings.redis, …). A new setting needs the field there, an entry in.env.example, and — if a container needs it — the compose files.langgraph.json— graph registry (graphs), graph import paths (dependencies), custom app (http.app), auth handler (auth.path), store scoping (store.scopes), thread TTL (checkpointer.ttl). Resolved viaGRAPHS_CONFIGenv →./langgraph.json. Two siblings exist for testing:langgraph.e2e.json(adds the carrier graphs fromtests/e2e/graphs/, used by everymake e2e-*target viadocker-compose.e2e.yml) andlanggraph.auth.json(JWT mock auth stack).
Adding things
A new graph
Read src/graphs/README.md first — it defines the two authoring paradigms
(explicit topology vs composed agent), the canonical package layout
(kardcraft_main/ / research_agent/), and the growth rules.
- Create
src/graphs/<name>/following the canonical layout. - Register in
langgraph.jsonundergraphs, pointing at<name>/graph.py, and add"src/graphs/<name>"to the hatchpackageslist in pyproject.toml. - Restart. A default assistant with a deterministic UUID (
uuid5(namespace, graph_id)) is created automatically. - Unit tests go in
tests/graphs/<name>/and run withmake test.
A new API endpoint
- Router in
controller/http/routers/<name>.py(thin: decode → authorize → call use case → map errors → encode). Models indomain/, logic inusecase/. - Register in
app/main.py_include_core_routersand add an OpenAPI tag. - Tests at all applicable levels (unit + integration; e2e if it touches a running server).
For endpoints that belong to your product rather than the protocol server, prefer
http.app in langgraph.json pointing at your own FastAPI app (see
src/http_app.py and src/kardcraft/api.py) — it is merged in without touching
src/agent_server/.
A new domain (assistant-like entity)
Work outward, mirroring how threads does it: domain/<name>.py → ORM table in
repo/orm.py → framework schema: make migrate-create → service in usecase/ → router in
controller/http/routers/ → wiring in app/main.py → tests.
The hub (src/hub/: skills + MCP connections + OAuth)
An application-layer domain package (same tier as src/kardcraft/src/ml), not part
of the protocol server: routes register through http_app.py, tables migrate through
its own alembic chain (src/hub/migrations, alembic_version_hub — never the
framework chain), and graphs import its loaders directly. Three trust tiers, user
overriding registry overriding builtin — src/hub/ is the source of truth (the
code and its docstrings carry the design; there is no separate design document).
Rules that keep it safe:
- The hub is runtime-agnostic. It is the capability plane for EVERY agent
runtime (LangGraph graphs today, a DeepSeek Harness run tomorrow): skills
and MCP connections are modeled on user identity, consumed by graphs through
injected loaders and by other runtimes through the REST/proxy surface.
Nothing in
src/hub/may import LangGraph/deepagents concepts. - The framework keeps only ports.
auth/policy.py(policy engine, typed vocabulary —ResourceTypeis a value object, families declared by consumers),infra/crypto.py(Fernet), andmcp_loader's injectedconnection_provider.with_mcp_tools(user_scoped=True)without a provider fails at graph-authoring time. - stdio MCP is a deployment privilege. User-tier connections are
streamable_httponly; never accept acommandfield from a user-facing API. - Credentials are write-only and encrypted at rest.
EncryptedJson(Fernet) at the column type boundary; responses return header key names only. - User skill scripts are inert by default. They land in the run's ephemeral state
filesystem (or the sandbox FS when
SANDBOX_PROVIDERis set), not the pod disk. The execution bridge isgraphs/shared/sandbox.py:montyruns Python-only in capability-locked subprocess workers (preferred lightweight tier),localis dev-only (no isolation),daytona/e2brun off-pod — never execute user scripts with the server's identity in production. - Skill tiers are overlays, never seeds. Package skills stay the git-versioned
default; registry rows (
/registry/skills, admin writes via the policy engine, reads open to any authenticated caller) and user rows only override by name. Deleting an override reverts to the lower tier; a registry row withenabled=falseis a tombstone masking the same-named skill in every tier. Graphs materialize per run in precedence order/skills/(builtin) →/registry-skills/→/user-skills/. - OAuth rides the MCP SDK (
OAuthClientProvider), never a hand-rolled flow: hub implements only TokenStorage (memory dev / Redis prod; DCR in Postgres) and the browser-interrupt UX (LangGraph interrupt with connect_url). - Authorization funnels through the policy engine port (async), constructor-injected;
CompositePolicyEngineAND-composes additive rules. Externalizing is a deployment choice: setOPA_URLfor the OPA sidecar (deployments/opa/), or implement the same port for OpenFGA/SpiceDB. - MCP reliability/UX building blocks: vendored circuit breakers per server
(
infra/circuit_breaker.py, Redis-shared when the broker is on), stringified-JSON args repair, MCP Apps (SEP-1865) capability advertisement + app-only tool filtering + host proxy routes (/hub/mcp/{name}/...).
Testing
- pytest, async-aware (
asyncio_mode = auto). Arrange-Act-Assert; names describe behavior (test_returns_404_when_assistant_not_found). tests/unit/— mocked deps, mirrors the src layers (test_usecase/,test_repo/,test_controller/,test_auth/,test_infra/,test_config/,test_app/,test_domain/).tests/integration/— FastAPI TestClient withDummySessionBasesession overrides (seetests/fixtures/).tests/e2e/— real server + real DB via docker compose; run throughmake e2e-*, never against a hand-started server.prod_onlymarks Redis-worker tests,auth_onlythe JWT-mock suite.e2e/multi_instance/is manual-only.- Mock at the driver layer, not SQLAlchemy, when bypassing SQLAlchemy.
API compatibility (STRICT)
This server speaks the Agent Protocol so the LangGraph SDK, Agent Chat UI and LangGraph Studio work against it unchanged. A request field that is accepted and quietly ignored is the compatibility bug that hurts most: the client sees success and the behaviour it asked for never happens.
- Every field the SDK can send either changes behaviour or returns 422. Never accept a field and ignore it. Not implemented yet? Declare the field and reject any value that differs from current behaviour with a clear error.
- One exception: fields that configure a system outside this server. A field whose only
effect is on a LangSmith-side service (e.g.
langsmith_tracer) may be inert, but it must be declared and listed inDOCUMENTED_NOOP_KEYSintests/unit/test_usecase/test_event_streaming/test_spec_params.py. Nothing else may be inert. - Declare accepted keys. Agent Protocol v2 handlers keep an explicit key set
(
RUN_START_KEYS,INPUT_RESPOND_KEYSinusecase/streaming/v2/commands.py) and log unknown keys at warning level. Do not useextra="forbid"on request models: the Python SDK always sends default-valued booleans, so a new SDK field would break every client. - Pin the contract with a drift test.
test_spec_params.pyvendors the protocol param shapes and asserts the handler key sets cover them;_SPEC_TUPLESintests/unit/test_auth/test_registry.pydoes the same for route authorization. A new SDK field must fail CI, not reach a user. - Match public names and defaults. Config keys, env vars and enum values follow the public LangGraph Platform docs where one exists, so users can move between deployments without changing payloads.
Conventions inherited from upstream (keep them)
- Type annotations on everything — every parameter and return type,
X | Nonesyntax. - Absolute imports (
agent_server.*), always at the top of the file. - Guard clauses and early returns; specific exceptions only; never swallow.
- No mutable default arguments; 5+ parameters → keyword-only.
- Tenant-scoped DB access must filter
user_id == user.identityin the WHERE clause —@auth.onhandlers are default-allow. - Secrets only in
.env; never log tokens/passwords.
