The function that would have taught my engine real estate

I build products solo, plus one assistant engine meant to serve all of them. The engine is deliberately generic: nothing in it names a business type. The first product to embed it is bestierealestate.com, a real-estate platform I build for a client in Thailand.

Then the assistant needed to search property listings. Every agent framework offers the easy answer: a decorated search_properties function next to the agent loop. I almost wrote it. Then I saw the trap. The instant that function lives inside the engine, the engine has learned real estate. Every future product inherits a real-estate assistant. Multi-tenancy — one engine serving many products — dies with one decorated function.

The other obvious road was already closed: my own MCP server security review scored a public MCP endpoint on the engine 0/10 — that story has its own post. What I shipped instead: an LLM tool calling architecture where the tool contract is data — published by Go, enforced by Python, toggled in Svelte.

One engine, three languages, one developer

Everything lives under one framework (SBX). Product backends are Go; admin surfaces are SvelteKit; the assistant engine is Python — a PydanticAI agent loop over a LiteLLM router, behind FastAPI.

So the tools live in the product: Go code answering HTTP calls. The loop that calls them lives in the engine: Python. The switches that enable them live in an admin UI: Svelte. One contract, three languages, no team to keep them synchronized. Just me.

flowchart LR
    A["Svelte admin kit<br/>config · tool toggles · chat"] -->|persist config| B["Go product API<br/>capability descriptors + tool endpoints"]
    B -->|MCP-shaped descriptors| C["Python engine<br/>PydanticAI loop · guards · RBAC"]
    C -->|tool calls over HTTP| B

Three standard answers, and the edge that broke each one

Host an MCP server. MCP (Model Context Protocol) is a standard way for a model runtime to discover and call tools. It fits this problem exactly and has real ecosystem momentum. It died in that security review. The engine process holds provider API keys, the live assistant config, and the whole conversation store; a public protocol endpoint would expose all three. The honest variant — a separate gateway service — is another deployment for a solo operator to run, monitor, and patch, for zero current external consumers. Deferred, deliberately.

Define tools in Python, next to the loop. A decorated function; the schema is inferred from the signature. PydanticAI does this well, and the schema cannot drift from the code because it is the code. It died on multi-tenancy, as above. It also couples releases: every new tool in any product becomes an engine change. The engine already had six behavioral ports as typing.Protocol interfaces; none names a business concept. Tools written inside it would be the only thing breaking that property.

Share a spec and generate code. OpenAPI or protobuf as the single source; generate a Go server stub, a Python client, TypeScript types. Proven at scale. It died on operational weight: three toolchains wired into three builds. The drift question does not disappear — it becomes "did every consumer regenerate?", still a discipline problem, now with more machinery. As the only engineer, I am the discipline.

The clue that reorganized my thinking: MCP has two separable parts — a wire protocol and a descriptor shape. I had rejected the server. Nothing forced me to reject the shape.

An LLM tool calling architecture without the server: contracts as data

Each business module declares its agent-callable surface as MCP-shaped Tool and Skill descriptors in governance YAML. A descriptor is a small data record: id, kind, endpoint, a description that is the model's calling contract, a JSON-Schema input (JSON Schema describes the shape of JSON data), MCP-style annotations, and an RBAC level (role-based access control: who may call it).

The product's Go API does three things:

  • It serves the descriptors at GET /api/assistant/capabilities (database-independent, 60-second cache) and hosts the plain HTTP endpoints the tools invoke.
  • A publication filter lets only side-effect-free, unprivileged tools appear on the unauthenticated endpoint.
  • A conformance test deep-compares the served payload against the canonical YAML; contract drift fails CI before merge.

A descriptor, abridged:

- id: properties.search
  kind: listing
  endpoint: GET /api/properties
  description: >
    Search published listings. Set a filter only when the
    visitor stated it; prices are minor units (satang).
  input_schema:
    type: object
    properties:
      max_price: { type: integer, minimum: 0 }
  annotations: { readOnlyHint: true }
  rbac: none

On the Python side, the assistant is a thin app: the framework package plus a config the product registers and persists. The config reloads every turn: an admin edit applies on the next message, no redeploy. A capability registry turns each descriptor into a generic HTTP-calling tool. Arguments are validated against the descriptor's JSON Schema. A deny-floor refuses any privileged tool unless an authorized actor is bound to the session — an unauthenticated privileged call is impossible even if a descriptor leaks through. Guards sit at the same boundary: a prompt-injection screen, an adaptive throttle that slows bursts before rejecting them, and per-turn spend caps.

The Svelte piece is an admin component kit: configure the agent, toggle tools, curate external knowledge and grounding sources, order a model fallback chain with per-token costs, and try it in a streaming chat with structured result cards. Adding a tool needs no engine edit: declare it, toggle it, it appears.

Honest results

The real-estate deployment now differs from a generic one by a base URL and a schema name. The workspace carries roughly 540 test functions (grep-counted, not a quality badge), and the agent-loop tests run on PydanticAI's TestModel/FunctionModel doubles at zero LLM spend.

The cost: descriptors are one more artifact to govern, and the original hand-wired tools stay inside the engine — one emits structured result cards, a side effect the generic executor cannot replicate yet.

Where this AI assistant architecture applies

The general problem: one runtime executes capabilities that another codebase owns, across a language or trust boundary. Common versions:

  • an internal tool platform feeding several agent deployments;
  • a plugin system where contributors ship operations to a host they do not compile into;
  • a multi-tenant SaaS where each tenant enables a different capability subset.

Each time, contract-as-data plus a conformance test beats shared code: producers and consumers version independently.

Who should not do this: with one product, one language, and one team, decorate functions next to your agent loop and move on — a descriptor layer is pure overhead there. The pattern pays off only when a second consumer, second language, or second trust zone is real.

What's still ugly

Plenty. The engine's streaming still emits one settled chunk; token-by-token deltas exist only in my prompt management tooling, Prompt Studio, not in the product chat engine. The Skill layer (guarded procedures over tools) and the Agent policy layer are designed, not built; only Tools are live today. Grounding is a keyword-ranked v1; pgvector is the decided path but not yet in code, and I won't claim otherwise. And the hand-wired tools mean the registry is not yet the single execution path.

Starting today, I would write the conformance test first: cheap, and it turned "we agreed on a contract" from a hope into a CI failure. If external exposure is ever needed, the answer is a separate purpose-built MCP gateway — never a socket on the engine.

Takeaways

  1. A protocol is a shape plus a transport; you can adopt them separately. The security review killed my MCP server, not MCP's descriptor format.
  2. Contracts that cross languages should be data checked by a test, not code generated into every consumer. The conformance test made drift a CI failure, not a runtime surprise.
  3. Enforce authorization twice, independently: filter at publication so privileged tools never leave the building, and refuse at execution unless an actor is bound. Either layer alone eventually leaks.
  4. Per-turn config reload is underrated for admin-tuned agents. My client's edits apply on the next message; a prompt change ships no release.
  5. If your agent loop cannot run at zero LLM spend, you will under-test it. My ~540 tests exist because running them costs nothing.

Notes and references

The engine, prompt tooling, and admin kit live inside my SBX framework; the prompt side is public as Prompt Studio (promptstudio.shredbx.com), and I write at shredbx.com.

Related projects