▲ Agents in production
The agent proposes, the backend executes.
By César Soto · 6 min read
▲ In this article
▲ In short
- The rule I follow to put an AI agent in front of a transactional backend fits in one line: the agent proposes and the backend executes.
- Everything that leaves the agent crosses a validated schema, and from there on deterministic code handles it: a domain service with the same rules as any other input.
- With that boundary, a bad model output lowers the quality of an answer and leaves data integrity untouched.
This week someone asked me on LinkedIn how I structure the boundary between agentic logic and the deterministic, transactional processes of a backend. Here is the full answer, with a TypeScript and Zod example written for this article: a support agent that proposes a refund.
Where does the boundary between the agent and the backend go?
The boundary goes at a validated schema: the agent proposes and the backend executes. Above the schema lives the non-deterministic part, which can be repeated at no cost. Below it lives ordinary code, with transactions that happen exactly once.
If it fails validation, the model call is repeated. Nothing has been written yet.
Four pieces hold the boundary, and each one answers a different way an agent can fail.
- Typed output. The agent returns data with a declared shape, and anything that does not match is dropped before it touches the system.
- Domain service. The code that writes to the database is the same service that serves a form or an API, with the same validations.
- Scoped tools with an injected tenant. The server decides which data the agent works on; the model has no way to choose it.
- Idempotency on the deterministic side. The queue can retry a model call and never a transaction.
What is a typed output?
Zod is a TypeScript library for declaring that shape once and getting two things from it: runtime validation and the static type. The schema below is the entire contract between the agent and the backend.
import * as z from "zod";
// The only thing the agent can return: a proposal.
export const RefundProposal = z.object({
orderId: z.string().min(1),
amountCents: z.number().int().positive(),
reason: z.enum(["damaged", "late", "duplicate_charge"]),
note: z.string().max(280),
});
// No tenantId or userId: the model does not decide those.
export type RefundProposal = z.infer<typeof RefundProposal>;
The schema describes a proposal, and it shows in what it leaves out. The reason is an enum of three values, so the agent cannot invent a fourth. The amount is a positive integer in cents. The tenant and the user are absent because the server already knows them.
What happens when the agent's output crosses the boundary?
The output comes in as unknown and leaves as a type or as an error; there is no third path. This function is the whole boundary.
export async function handleAgentOutput(
raw: unknown,
ctx: ServerContext,
) {
const parsed = RefundProposal.safeParse(raw);
if (!parsed.success) {
// Retrying here is safe: nothing has been written yet.
return askModelAgain(parsed.error);
}
// From here on, deterministic code.
return refunds.propose({
...parsed.data,
tenantId: ctx.tenantId, // from the session
requestedBy: ctx.userId,
idempotencyKey: ctx.jobId, // one per job
});
}
If the schema rejects the output, the error goes back to the model and it tries again. Repeating that call costs tokens and nothing else. If the schema accepts it, the proposal is completed with server context and handed to the domain service.
Who writes to the database?
A domain service writes, the same one a person would use from the interface. To that service the agent is one more client and gets the same treatment as any other input.
export const refunds = {
async propose(input: ProposeRefundInput) {
return db.transaction(async (tx) => {
const done = await tx.refunds.findByKey(
input.tenantId,
input.idempotencyKey,
);
if (done) return done; // the queue retried: same answer
// Business rules: the schema cannot reach this far.
const order = await tx.orders.find(
input.tenantId,
input.orderId,
);
if (!order) throw new DomainError("order_not_found");
if (input.amountCents > order.refundableCents) {
throw new DomainError("amount_exceeds_refundable");
}
// The backend decides a person approves this.
return tx.refunds.insert({
...input,
status: "pending_approval",
});
});
},
};
The schema validates shape and the service validates the business. An amount of 50,000 cents has a valid shape, and only the service knows the order allows 30,000. That rule existed before the agent did, which is why the agent does not need its own copy.
How does the agent reach the system's data?
Only through scoped tools, and the tenant is injected by the server when they are built. The model sees a signature with a single parameter.
// The model sees this signature. There is no tenantId to fill.
const GetOrderInput = z.object({
orderId: z.string().min(1),
});
export function buildTools(ctx: ServerContext) {
return {
getOrder: {
description: "Looks up an order by id.",
inputSchema: GetOrderInput,
execute: (input: z.infer<typeof GetOrderInput>) =>
orders.find(ctx.tenantId, input.orderId), // from the server
},
};
}
A prompt injection can talk the model into asking for another customer's order. The query still runs with the session's tenant and comes back empty. Tenant isolation is guaranteed by the tool's signature and stops depending on what the prompt says.
What can go wrong, and where does it stop?
Each kind of failure stops at a different piece, and only one reaches the user.
| What goes wrong | Where it stops | What happens to the system |
|---|---|---|
| The output does not have the expected shape | Schema | The model call is repeated; nothing was written |
| The shape is valid and the value is impossible in the business | Domain service | A domain error, same as with a form |
| The agent asks for another customer's data | Tool with injected tenant | The query comes back empty |
| The job runs twice | Idempotency key | The second run returns the result of the first |
| The proposal is valid and wrong | It does not stop at the boundary | Quality drops; the data stays intact |
“A bad output degrades quality, never data integrity.”
The last row is the one left open, and it is a quality problem: you work on it with evals, better specs and human approval for the actions that warrant it. In the example, the refund is created as pending_approval for that reason.
Does the same rule apply to building software with agents?
Yes, it is the same boundary under other names. In agent-based development, the agent proposes a PR and the gates execute the merge: automated reviewers, human review proportional to risk, and QA.
In both cases the agent works freely on one side and the system keeps the last word on the other. I describe that process in what a harness is and in PeakSyn's method.
Where do you start in a system that already exists?
- Pick one agent action that writes data today. Just one, the one that worries you most.
- Declare its schema. Leave out everything the server already knows: tenant, user, permissions.
- Route the write to the service that already exists. If the agent has its own path to the database, that path is the first one to close.
- Remove tenant identifiers from tool signatures. Inject them from the session.
- Add the idempotency key in the service. With its unique index.
If you want to look at how this boundary would fit your product, you can book a 30-minute conversation.
Frequently asked questions
Doesn't the model provider's structured output already solve this?
It solves part of it. Provider-side structured output reduces shape errors, and I still validate on the server, because the contract is mine and has to survive a model change. Business rules sit outside any schema and live in the domain service.
Do I need Zod to apply this rule?
No. Zod is the usual choice in TypeScript; in Python, Pydantic plays the same role, and any JSON Schema validator works. What matters is that the shape is declared once and validated on the server before the data is used.
So the agent never writes to the database?
It never writes directly. Its writes go through domain services that apply the same validations as for any other input, inside a transaction with an idempotency key.
What about irreversible actions, such as charging a card or sending an email?
They are treated as transactions. The agent proposes them, the backend executes them exactly once with an idempotency key and, when the impact warrants it, they wait for a person's approval.
Does this boundary stop the agent from being wrong?
No. It stops an agent's mistake from damaging data. A valid, wrong proposal is still possible, and that part is handled with evals, specs and human approval.
References
Want to apply it in your team? Let's talk for 30 minutes.
Free, on video, with César Soto.
Book a conversation