Let the Agent Propose, Not Write
The single boundary that separates an LLM feature you can point at production data from a demo you cannot - plus schema-validated extraction, provider fallback and cost accounting as engineering concerns rather than prompt concerns.
The demo is always impressive. You say “add everyone who opened the March campaign to a new segment and tag them warm,” and it happens.
The question that decides whether it ships is different: what happens when the model misunderstands? If the answer involves an UPDATE that already ran, you do not have a feature. You have an incident generator with a friendly interface.
ECamp has a Gemini-powered agent for contact management, driven by natural language and voice. It runs against production data. It does so because of one architectural rule.
The rule
The agent never writes. It proposes.
The model’s output is not an action. It is a structured proposal, validated against a declared schema, subjected to the same permission checks a human request would face, and executed by ordinary application code that has no idea an LLM was involved.
class AddToSegment(BaseModel):
action: Literal["add_to_segment"]
filter: ContactFilter # a constrained query, not raw SQL
segment_name: str
tags: list[str] = []
class DeleteContacts(BaseModel):
action: Literal["delete_contacts"]
filter: ContactFilter
confirm_count: int # the model must state how many it expects
Proposal = Annotated[
Union[AddToSegment, DeleteContacts, ...],
Field(discriminator="action"),
]
The model chooses among operations that exist. It cannot invent one, cannot compose raw SQL, and cannot reach anything outside the declared set. An intent that maps to no operation fails validation and becomes a clarifying question - which is exactly the right behaviour.
Note confirm_count on the destructive operation. The model must state how many records it believes it is affecting. If the count and the filter disagree by more than a small margin, the proposal is rejected before execution. A misunderstanding becomes a rejection, not a deletion.
The three checks between proposal and execution
Schema validation. Does this parse into a known operation with well-typed fields? Invalid output triggers a repair retry with the validation error included as context - models are markedly better at fixing a specific complaint than at getting it right on the second blind attempt. After a small number of failed repairs, it is an error, not an infinite loop.
Permission resolution. The proposal runs through the exact authorization path a human API request would. Same tenant scoping, same role checks, same subscription gate. The agent is not a privileged caller; it is a caller that happens to have been assembled by a model.
Blast radius. Any operation exceeding a threshold - record count, irreversibility, cost - requires explicit human confirmation with the effect described in plain language: “This will permanently delete 3,412 contacts across 2 segments.” Not the model’s summary of what it thinks it is doing. The application layer’s description of what will actually happen.
Together these mean the worst case of a misunderstood instruction is a rejected proposal or a confirmation dialog. Never a silent mutation.
Schema-validated extraction, everywhere
The same discipline applies outside agents. In NewsCrawl, the LLM extracts structured fields from article text, and the pipeline treats every response as untrusted input:
- Parse against the declared schema. Reject on mismatch.
- Retry with the validation error attached, bounded.
- On repeated failure, record the document as extraction-failed and continue. One bad document must not stop a pipeline.
The alternative - parsing hopefully and writing what you got - produces a database where some fields are strings, some are strings containing JSON, and one is an apology. Every downstream consumer inherits that mess forever.
Providers are dependencies, so treat them like dependencies
An LLM API is a third-party network service with rate limits, latency spikes and outages. Nothing about it deserves special trust.
Fallback. Primary provider rate-limits or times out, the call routes to a secondary. The pipeline degrades in quality rather than stopping. Which requires that prompts and schemas are not tuned so tightly to one provider that they break on another - a constraint worth accepting up front.
Timeouts and circuit breaking. A hanging call holds a worker. Aggressive timeouts, and a breaker that stops sending to a provider that is failing consistently, so you fail fast instead of queueing into a wall.
Cost accounting per call. Tokens in, tokens out, model, cost, attributed to the document or campaign that caused it:
usage.record(
provider=provider.name,
model=provider.model,
prompt_tokens=r.usage.prompt_tokens,
completion_tokens=r.usage.completion_tokens,
cost_usd=provider.price(r.usage),
attributed_to=doc_id,
)
This is the least glamorous part and the one I would keep if I could only keep one. Without it, “is this feature worth its cost?” is a discussion. With it, it’s a query. It is also how you find the prompt change that quietly tripled your bill, in the week it happens rather than at the end of the month.
Idempotency, again
Retries mean the same extraction can run twice on the same document. Same rule as everywhere else in the system: key results on the content, upsert rather than insert, and a repeated call is a wasted API call rather than a duplicated row.
Cache on that key too. In a pipeline that reprocesses documents after a schema change, the cache is often the majority of the savings.
Where the intelligence actually goes
The pattern underneath all of this: the model supplies intent; the application supplies authority.
The model is genuinely good at turning “everyone who opened the March campaign, tag them warm” into a structured filter. It is not good at deciding whether the caller may do that, whether three thousand records is a reasonable blast radius, or whether the operation is reversible. Those are questions with correct answers your code already knows.
Keep the boundary sharp and you get the useful part of an LLM without handing it the keys. Blur it and you have built a system whose safety properties depend on a probability distribution.