Back to Insights
AI & Machine Learning•From Prompt Engineering to Deterministic Engineering: Why AI Agents Need Client-Side Functions, Not More Prompts•opinion•November 9, 2026•8 min read

From Prompt Engineering to Deterministic Engineering: Why AI Agents Need Client-Side Functions, Not More Prompts

Prompts are a fragile interface for production AI agents. Discover why deterministic client-side functions are replacing prompt engineering as the core design pattern.

T
Tamiz UddinFull-Stack Engineer

The AI agent stack has a dirty secret: the more we rely on prompt engineering to control behavior, the less we actually control it. Every production agent I've seen struggling with reliability, cost, or security is almost always drowning in prompt complexity — massive system prompts, few-shot examples, chain-of-thought scaffolding, and regex-based parsing. The irony is that we've spent a decade building deterministic software engineering practices, and now we're throwing them away to gamble on token probabilities.

There's a better way. The future of AI agents isn't more prompts — it's deterministic engineering: pushing every action, decision, and data transformation that can be encoded in code out of the prompt and into client-side functions. The prompt becomes a reasoning interface, not a control plane.

The Illusion of Prompt Engineering

Prompt engineering works beautifully in demos. You write a few paragraphs of instructions, sprinkle in some examples, and the model produces exactly what you want. It feels like magic — and that's the problem. Magic isn't reproducible.

In production, prompts degrade along four axes:

  1. Non-determinism: Same input, different output. The temperature is 0, but the model still samples from a distribution. For any non-trivial task, you cannot guarantee that a prompt will produce a specific result. That's not a bug; it's the definition of a stochastic system.
  2. Brittleness: A single word change in your system prompt can silently alter behavior. Model updates (even patch versions) shift the probability landscape. You can't unit test a prompt the way you unit test a function.
  3. Security: Prompts are an injection surface. User data concatenated into a prompt is an open door for prompt injection. The more you rely on prompts to make decisions, the bigger the attack surface.
  4. Cost: Every token in a prompt is money — and you pay for it on every single call. A 5,000-token system prompt multiplied across millions of requests is not a rounding error; it's a line item.

Prompt engineering treats the LLM as a computer. It's not. It's a probabilistic reasoning engine. When you need a deterministic operation — a database query, an API call, a calculation, a state transition — you should never ask the LLM to do it. You should ask it to decide to do it, and then let code execute.

What Deterministic Engineering Actually Means

Deterministic engineering is not "no prompts." It's a re-architecture of the agent loop where the LLM is a reasoner and the runtime is an executor. The core principle: anything that can be expressed as a function should be a function.

Consider a typical agent task: "Find the user's order and update its status to 'shipped'." The naive prompt-driven approach:

text
System: You are an order management assistant. You can query the database and update orders. Use the getOrder tool and updateOrderStatus tool.
User: Find order #12345 and update its status to 'shipped'.

The model might call getOrder(12345), then decide to call updateOrderStatus(12345, 'shipped'). But it might also hallucinate a fake order, or call the update tool with the wrong status, or simply respond with a summary without calling any tool. You're at the mercy of a probability distribution.

Now the deterministic version — the agent runtime defines the contract, not the prompt:

ts
// agent/tools/order.ts
export const orderTools = [
  {
    name: 'getOrder',
    description: 'Retrieve an order by ID',
    parameters: z.object({ orderId: z.string() }),
    execute: async ({ orderId }) => db.orders.findUnique({ where: { id: orderId } })
  },
  {
    name: 'updateOrderStatus',
    description: 'Update the status of an order',
    parameters: z.object({ orderId: z.string(), status: z.enum(['PENDING', 'SHIPPED', 'DELIVERED']) }),
    execute: async ({ orderId, status }) => db.orders.update({ where: { id: orderId }, data: { status } })
  }
];

The prompt becomes minimal: "Update order #12345 to shipped." The model's only job is to select the right function and arguments. The runtime validates the arguments against a schema, executes the function, and returns a structured result. If the model hallucinates, the schema validation fails, and the runtime can retry or escalate. The behavior is reproducible, testable, and secure.

The Hybrid Model: Prompts for Reasoning, Functions for Action

The shift isn't "replace prompts with code" — it's "limit prompts to what they're good at." LLMs are exceptional at:

  • Semantic understanding: interpreting ambiguous user intent
  • Planning: decomposing a goal into sub-steps
  • Judgment: choosing between alternatives based on context

They are terrible at:

  • Exact computation: arithmetic, string manipulation, date math
  • State mutation: writing to a database, calling an external API
  • Validation: ensuring a value conforms to a schema
  • Idempotency: guaranteeing a side effect happens exactly once

So the deterministic engineering rule is simple: the prompt decides what to do; the function decides how to do it. The prompt can say "update the order," but the function updateOrderStatus is the only thing that actually updates the order. The prompt can't update anything; it can only request an update.

This is the same pattern we've used in software for decades — separation of concerns. The LLM is the controller, the functions are the model and view. The difference is that we now have a controller that can reason about novel situations, but we've stripped it of the ability to directly manipulate state. That's a massive win for safety and observability.

Why This Matters for Production Systems

Testability

You can write unit tests for updateOrderStatus with mocked database connections. You can test edge cases: missing order, invalid status, concurrent updates. You cannot test a prompt the same way. The best you can do is golden-file testing, which breaks every time the model updates. Deterministic functions give you the same confidence you have in any other codebase.

Observability

With functions, every action is a structured log entry: tool: updateOrderStatus, args: { orderId: '12345', status: 'SHIPPED' }, result: { ok: true }. You can trace an agent's entire trajectory, replay it, and debug failures. With prompts, you only see token counts and latency. You can't know why the agent did something, only that it did.

Security

Functions are sandboxed. They have explicit input schemas, explicit permissions, and no access to the prompt's internal state. A malicious user can inject instructions into a prompt, but they can't inject arguments into a function call — the schema validation rejects anything outside the contract. The attack surface moves from "free-form text" to "typed interfaces," which is exactly where we want it.

Cost

Every token you move out of the prompt and into code is money saved. A 10,000-token system prompt costs the same whether the task is trivial or complex. A function call costs nothing but CPU. When you make functions the workhorses, prompts shrink to the size of a decision boundary — and your bill shrinks with them.

The Practical Path Forward

If you're building an agent today, here's the deterministic engineering checklist:

  1. Define the tool contract first: Write the TypeScript/JSON schemas for every action your agent can take. Make them strict — enums, required fields, no any.
  2. Keep the prompt minimal: The system prompt should describe the goal and the boundaries, not the procedures. Don't teach the model how to call tools; teach it when to call them.
  3. Validate everything: Every function argument is validated at runtime. Never trust the model's output. If validation fails, retry with a corrective message — don't blindly execute.
  4. Make functions idempotent: If your agent retries a function, it should be safe to call twice. Use idempotency keys, check-then-act patterns, and transactional boundaries.
  5. Log the trajectory: Record every tool call, its arguments, and its result. This is your debugging goldmine.
  6. Move logic out of the prompt as fast as you can: If you find yourself writing "if the user says X, then do Y" in a prompt, that's a code smell. Move it into a function.

The Inevitable Shift

The industry is already moving this way. Function calling, structured outputs, and agent SDKs are all steps toward deterministic engineering. The next generation of AI developers won't be "prompt engineers" — they'll be software engineers who happen to use LLMs as a component. The prompt will be treated like a configuration file, not the source of truth.

If you're still spending hours crafting the perfect system prompt, you're solving the wrong problem. The prompt is not the product; the deterministic system around it is. The sooner you stop trying to control the model with words and start controlling it with contracts, the sooner your agents will become reliable, testable, and production-ready.

That's the real engineering challenge of the agentic era. And it's not about prompts at all.


This article was originally published on Tamiz's Insights. Explore more technical deep-dives at tamiz.pro.

Part II: The Deterministic Architecture in Practice

If the thesis of Part I is that prompts alone cannot carry the weight of agentic systems, the corollary is that we must move the contract — the precise definition of what an agent can do, how it does it, and what it returns — out of the model's latent space and into executable code. This is not a rejection of LLMs; it is a rejection of ambiguity as a design principle.

Let me show you what that looks like in a real, runnable system.


Section 1: The Anatomy of a Deterministic Agent

A deterministic agent is not a monolithic loop. It is a composition of three layers, each with a strictly defined responsibility:

  1. The Orchestration Layer — decides what to do next. This is the only place where the LLM is consulted, and its output is constrained to a finite set of intents.
  2. The Execution Layer — a registry of typed, client-side functions. Each function has a schema, a validator, and a pure implementation.
  3. The State Layer — a serializable, versioned state machine that tracks progress, retries, and side-effect outcomes.

The key insight: the LLM never executes anything. It only classifies. The execution is done by code you control, with types you define, and tests you wrote.

Here is the core type system in TypeScript:

typescript
// types.ts
export type Intent = 
  | { kind: 'search_products'; query: string }
  | { kind: 'add_to_cart'; productId: string; quantity: number }
  | { kind: 'checkout' }
  | { kind: 'escalate_to_human'; reason: string };

export interface FunctionSpec<TInput, TOutput> {
  name: string;
  description: string;
  inputSchema: ZodSchema<TInput>;
  outputSchema: ZodSchema<TOutput>;
  execute: (input: TInput, ctx: AgentContext) => Promise<TOutput>;
  isPure?: boolean; // if true, can be memoized
  timeoutMs?: number;
  retries?: number;
}

export interface AgentContext {
  sessionId: string;
  userId: string;
  state: AgentState;
  logger: Logger;
}

The Intent type is the contract. The LLM is prompted with a system message that lists only these intents. It cannot invent new ones. If it tries, the output parser rejects it, and the orchestrator either retries with a corrective prompt or escalates.


Section 2: Building the Function Registry

The registry is a map from intent kind to a concrete implementation. It is the heart of the deterministic layer.

typescript
// registry.ts
import { z } from 'zod';

class FunctionRegistry {
  private registry = new Map<string, FunctionSpec<any, any>>();

  register<TInput, TOutput>(spec: FunctionSpec<TInput, TOutput>) {
    if (this.registry.has(spec.name)) {
      throw new Error(`Duplicate function registration: ${spec.name}`);
    }
    this.registry.set(spec.name, spec);
  }

  get<TInput, TOutput>(name: string): FunctionSpec<TInput, TOutput> {
    const spec = this.registry.get(name);
    if (!spec) {
      throw new Error(`Unknown function: ${name}`);
    }
    return spec;
  }

  listForPrompt(): string {
    return Array.from(this.registry.values())
      .map(s => `- ${s.name}: ${s.description}`)
      .join('\n');
  }
}

Now we register the actual implementations. Notice that each function is pure in its core logic — any side effects (database writes, API calls) are isolated behind an adapter interface, making the core unit-testable without mocks.

typescript
// functions.ts
const registry = new FunctionRegistry();

registry.register({
  name: 'search_products',
  description: 'Search the product catalog by keyword. Returns up to 10 results.',
  inputSchema: z.object({ query: z.string().min(2) }),
  outputSchema: z.object({ 
    results: z.array(z.object({
      id: z.string(),
      name: z.string(),
      price: z.number(),
      inStock: z.boolean(),
    })) 
  }),
  execute: async ({ query }, ctx) => {
    // Adapter call — the actual DB query is injected
    return ctx.adapters.catalog.search(query);
  },
  timeoutMs: 3000,
  retries: 2,
});

registry.register({
  name: 'add_to_cart',
  description: 'Add a product to the user\'s cart. Validates stock before adding.',
  inputSchema: z.object({ 
    productId: z.string(), 
    quantity: z.number().int().positive().max(99) 
  }),
  outputSchema: z.object({ 
    cartTotal: z.number(),
    itemCount: z.number(),
    added: z.boolean(),
  }),
  execute: async ({ productId, quantity }, ctx) => {
    const stock = await ctx.adapters.inventory.getStock(productId);
    if (stock < quantity) {
      return { cartTotal: 0, itemCount: 0, added: false };
    }
    const cart = await ctx.adapters.cart.add(ctx.userId, productId, quantity);
    return { 
      cartTotal: cart.total, 
      itemCount: cart.count, 
      added: true 
    };
  },
  timeoutMs: 2000,
  retries: 1,
});

Notice that the execute functions are not calling the LLM. They are pure business logic, wrapped in a schema-validated contract. This is the "deterministic" part.


Section 3: The Orchestration Loop — Where the LLM Fits

Now we need the loop that ties it together. The LLM's job is to map a user utterance to an Intent. That's it. No free-form text generation, no "reasoning" about business logic, no hallucinated function names.

typescript
// orchestrator.ts
export class DeterministicOrchestrator {
  constructor(
    private llm: LLMClient,
    private registry: FunctionRegistry,
    private stateStore: StateStore,
    private logger: Logger,
  ) {}

  async handleUserMessage(userId: string, message: string): Promise<AgentResponse> {
    const sessionId = await this.stateStore.getOrCreateSession(userId);
    const state = await this.stateStore.loadState(sessionId);
    
    // 1. Convert the user message to a structured intent
    const intent = await this.classifyIntent(message, state);
    
    // 2. Execute the intent deterministically
    const result = await this.executeIntent(intent, { sessionId, userId, state });
    
    // 3. Update state
    const newState = this.applyStateTransition(state, intent, result);
    await this.stateStore.saveState(sessionId, newState);
    
    // 4. Generate a natural language response (optional, can be templated)
    const responseText = this.templateResponse(intent, result);
    
    return { text: responseText, state: newState, intent, result };
  }

  private async classifyIntent(message: string, state: AgentState): Promise<Intent> {
    const prompt = buildClassificationPrompt(this.registry.listForPrompt(), state);
    const raw = await this.llm.complete(prompt, { temperature: 0 });
    
    // Parse and validate — this is where we enforce determinism
    const parsed = parseIntent(raw, this.registry);
    if (!parsed.ok) {
      // Retry with corrective context, or escalate
      return this.handleParseFailure(message, state, parsed.error);
    }
    return parsed.value;
  }

  private async executeIntent(intent: Intent, ctx: AgentContext): Promise<any> {
    const spec = this.registry.get(intent.kind);
    
    // Validate input against the schema
    const input = spec.inputSchema.parse(intent);
    
    // Execute with timeout and retry logic
    return this.withRetry(spec, input, ctx);
  }
}

The critical function is classifyIntent. The prompt given to the LLM is not "help the user with their problem" — it is:

vbnet
You are an intent classifier. You MUST respond with a JSON object matching one of these intents:

{ "kind": "search_products", "query": "<string>" }
{ "kind": "add_to_cart", "productId": "<string>", "quantity": <int> }
{ "kind": "checkout" }
{ "kind": "escalate_to_human", "reason": "<string>" }

Rules:
- Do not invent new intents.
- Do not include extra fields.
- If the user's request is ambiguous, choose the most specific intent.
- If no intent fits, use "escalate_to_human".

User message: "${message}"
Current state: ${JSON.stringify(state.summary)}

This is a classification task, not a generation task. The temperature is 0. The output is constrained by JSON schema. The model cannot "helpfully" decide to call a function that doesn't exist.


Section 4: Error Handling and Recovery — The Hidden Determinism

The biggest source of nondeterminism in agentic systems is not the LLM — it's the failure modes of the tools it calls. A function can time out, return invalid data, or throw. A prompt-based agent will "reason" its way through these failures, sometimes correctly, sometimes not.

A deterministic agent handles errors with code, not vibes:

typescript
// retry.ts
export async function withRetry<TInput, TOutput>(
  spec: FunctionSpec<TInput, TOutput>,
  input: TInput,
  ctx: AgentContext,
): Promise<TOutput> {
  const maxRetries = spec.retries ?? 0;
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const result = await withTimeout(spec.execute(input, ctx), spec.timeoutMs);
      // Validate output against schema
      return spec.outputSchema.parse(result);
    } catch (err) {
      lastError = err;
      ctx.logger.warn(`Function ${spec.name} failed on attempt ${attempt + 1}`, { err });
      
      // Exponential backoff with jitter
      if (attempt < maxRetries) {
        await sleep(Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 100);
      }
    }
  }
  
  // All retries exhausted — escalate, don't hallucinate
  throw new FunctionExecutionError(spec.name, lastError);
}

The key line is the final one: escalate, don't hallucinate. A prompt-based agent might invent a fake error message or try a random alternative. A deterministic agent throws a typed error that the orchestrator catches and routes to a human or a fallback path.


Section 5: Testing Deterministic Agents

This is where the payoff is. Because the execution layer is pure and schema-validated, you can write unit tests that cover every branch without spinning up an LLM.

typescript
// functions.test.ts
import { describe, it, expect } from 'vitest';

describe('search_products', () => {
  it('validates input schema', async () => {
    const spec = registry.get('search_products');
    expect(() => spec.inputSchema.parse({ query: '' })).toThrow();
  });

  it('returns validated output', async () => {
    const mockAdapter = {
      catalog: { search: async (q: string) => [{ id: '1', name: 'Laptop', price: 999, inStock: true }] },
    };
    const ctx = { adapters: mockAdapter, logger: silentLogger };
    const result = await registry.get('search_products').execute({ query: 'laptop' }, ctx);
    expect(result.results).toHaveLength(1);
    expect(result.results[0].name).toBe('Laptop');
  });

  it('handles empty results', async () => {
    const mockAdapter = {
      catalog: { search: async () => [] },
    };
    const result = await registry.get('search_products').execute({ query: 'zzz' }, ctx);
    expect(result.results).toHaveLength(0);
  });
});

You can also test the orchestrator with a mocked LLM that returns predetermined intents:

typescript
// orchestrator.test.ts
it('escalates when LLM returns unknown intent', async () => {
  const mockLLM = { complete: async () => '{ "kind": "delete_database" }' };
  const orchestrator = new DeterministicOrchestrator(mockLLM, registry, mockStateStore, logger);
  
  const response = await orchestrator.handleUserMessage('user-1', 'delete everything');
  expect(response.intent.kind).toBe('escalate_to_human');
});

This is a level of determinism that prompt-based agents simply cannot offer. You can CI-test your agent's behavior without ever calling an LLM API.


Section 6: Performance — The Hidden Cost of Prompts

There is a performance argument too. Every time you put a decision in a prompt, you pay:

  • Latency: 1-3 seconds per LLM call, often multiple calls per user turn.
  • Tokens: The prompt grows with every tool description, every state summary, every past conversation.
  • Cost: You are paying for "reasoning" that is actually just a lookup table.

A deterministic agent pays these costs once — to classify the intent. The execution is local, typed, and fast.

typescript
// benchmark.ts
async function benchmark() {
  const orchestrator = new DeterministicOrchestrator(realLLM, registry, stateStore, logger);
  
  const start = performance.now();
  const response = await orchestrator.handleUserMessage('user-1', 'add 3 laptops to my cart');
  const duration = performance.now() - start;
  
  console.log(`Total round-trip: ${duration.toFixed(0)}ms`);
  console.log(`LLM calls: 1`);
  console.log(`Function calls: 1 (add_to_cart)`);
  console.log(`Response: ${response.text}`);
}

In production, you'll find that the LLM call dominates latency (typically 80-95% of the total). By reducing the number of LLM calls per user turn from 3-5 to 1, you get a 3-5x throughput improvement for the same infrastructure.


Section 7: Migration Strategy — From Prompt-Based to Deterministic

If you have an existing prompt-based agent, you don't need a rewrite. You need a surgical extraction.

Step 1: Inventory your tools. List every function your agent can call. Write a TypeScript interface for each.

Step 2: Define the Intent union. Create a discriminated union of all possible actions.

Step 3: Wrap each tool. Create a FunctionSpec for each tool, with input/output schemas.

Step 4: Change the system prompt. Replace the "you are a helpful assistant" prompt with the classification prompt.

Step 5: Add a fallback. For any user message that doesn't map cleanly, escalate to a human or a legacy prompt-based path.

Step 6: Run in shadow mode. Keep the old prompt-based agent running in parallel. Compare outputs. When the deterministic agent matches 95%+ of the time, switch over.

The migration is incremental. You can start with just the most critical functions (checkout, payments) and leave the rest in prompt-land. Over time, you move more functions into the deterministic layer.


Section 8: The Real Engineering Challenge — A Recapitulation

We began this article with the claim that the real engineering challenge of the agentic era is not prompts. By now, I hope the evidence is clear:

  • Prompts are for classification, not execution.
  • Functions are for execution, not classification.
  • The contract between them is a schema, not a hope.

The agentic era is not about making LLMs "smarter" or "more reliable." It is about building systems that contain the LLM's nondeterminism within a deterministic shell. It is about treating the LLM as a component with a well-defined interface, not as the system itself.

This is the shift from prompt engineering to deterministic engineering. And it is the only way we will ship agents that are safe enough for production, testable enough for CI, and fast enough for real users.


Section 9: Final Thoughts — What We Lose, What We Gain

We lose something when we constrain the LLM. We lose the ability to "figure it out on the fly." We lose the flexibility of a system that can adapt to novel situations without being explicitly coded for them.

But what we gain is far more valuable:

  • Reproducibility: The same input produces the same output. Every time.
  • Testability: You can write a unit test for your agent's behavior without an LLM.
  • Observability: You know exactly which function ran, with what inputs, and what it returned.
  • Safety: The agent cannot call a function that doesn't exist, cannot invent a tool, cannot "helpfully" delete a database.
  • Cost: You pay for one classification call, not five reasoning calls.

The future of AI agents is not "more prompts." It is "more contracts." It is TypeScript interfaces, Zod schemas, and state machines. It is deterministic engineering.

The LLM will always be the brain — but a brain needs a body with bones, muscles, and reflexes. The bones are the types. The muscles are the functions. The reflexes are the error handling.

That body is what we build now.


This article was originally published on Tamiz's Insights. Explore more technical deep-dives at tamiz.pro.