Margins est. 2026
← All writing
Post · June 2, 2026 · 6 min

The LLM is just an unreliable third-party REST API - Part I

I've been trying to get a mental model of agentic AI applications into something that I'm familiar with when thinking about building an application. The first thing that came to mind was web applicati…


I’ve been trying to get a mental model of agentic AI applications into something that I’m familiar with when thinking about building an application. The first thing that came to mind was web applications. You have your front end, you have your service layer, you have your data store on a high level. The following is an example of building a very crude, very simple CLI that given an array of reported issues or bugs will use an AI agent to go through a discriminated union and classify them in different buckets.

While I was thinking about this, obviously one of the major players in any agentic application is the large language model. And in past projects, I’m sure as it is right now for so many of you, one of the unpredictable and unreliable pieces were third party APIs, therefore, it is not too much of a stretch to say:

The LLM is just an unreliable third-party REST API

That’s the one-sentence mental hook. Every LLM-specific design decision collapses into something a backend programmer has already done, talking to Stripe, Twilio, a flaky internal microservice.

So, treat the LLM like a third-party API you don’t trust and your architecture writes itself.

Here’s what running the CLI on four representative bug reports actually produces, one input per classification bucket (full output in the repo at sample-cli-run.json):

[
  {
    "classification": "actionable_ticket",
    "report_id": "report-0",
    "original_input": "Deleted a project this morning. Now if I paste the old URL into Chrome it still loads our app shell...",
    "title": "Deleted project URL loads app shell with no navigation back to Projects",
    "severity": "medium",
    "category": "project_lifecycle",
    "extraction_confidence": "high",
    "expected_behavior": "Navigating to a deleted project's URL should either redirect the user to the Projects list...",
    "observed_behavior": "The app shell loads on the deleted project URL. The left sidebar shows 'No chats yet'...",
    "steps_to_reproduce": [
      "Create a project and note its URL.",
      "Delete the project via the normal project deletion flow.",
      "..."
    ],
    "engineering_notes": "The 404 error state is being rendered correctly at the data/API level, but the UI route handler...",
    "missing_information": ["Browser version / OS", "..."]
  },
  {
    "classification": "partial_ticket_needs_clarification",
    "report_id": "report-1",
    "original_input": "Contract review gave me a totally wrong answer about the indemnity clause...",
    "title": "AI incorrectly reports unlimited liability when contract clearly caps it at $1M",
    "severity": "high",
    "category": "ai_response",
    "...": "(all actionable_ticket fields, plus:)",
    "clarifying_questions": [
      "Could you share the PDF (or a sanitized version)?",
      "What was the exact question you typed to the AI?",
      "..."
    ],
    "what_unlocks_actionable": [
      "Providing the source PDF or the verbatim indemnity clause text...",
      "..."
    ]
  },
  {
    "classification": "too_vague_request_more_info",
    "report_id": "report-2",
    "original_input": "upload is broken",
    "interpretation": "The user reports that 'upload is broken' but provides no additional context...",
    "clarifying_questions": [
      "What type of file were you trying to upload?",
      "What exactly happens when the upload fails?",
      "..."
    ],
    "suspected_category": "upload"
  },
  {
    "classification": "non_bug_support_question",
    "report_id": "report-3",
    "original_input": "How do I export my project to Word?",
    "suggested_route": "feature_request",
    "suggested_response": "Thanks for reaching out! What you're describing isn't a bug report, but rather a question about available functionality...",
    "reasoning": "The user is asking how to perform an action and notes they can only see PDF in the menu..."
  }
]

Layer-by-layer mapping

The code lives at github.com/KazChe/bug-cli-ai-agent

Our layerWeb-app equivalentWhat both doWhy the analogy holds
src/index.ts (entry)Express/Fastify route handlerParses the incoming request, returns a responseSame job, different transport (stdin vs HTTP). Could swap to an HTTP handler tomorrow with zero changes to anything below.
src/cli/runner.ts (orchestration)Controller / use-case orchestratorValidates input shape, calls the service per item with bounded concurrency, builds the responseThis is literally a controller. Your runCli is your route handler’s body.
src/classifier/classify.ts (domain)Service / business logicEncapsulates the “what we actually do”This is the only layer that knows the business rule (“classify bug reports”). Could ship as a library.
src/llm/* (LLM boundary)Repository / external API client (think: Stripe SDK wrapper)Talks to the third party, returns typed responsesAnthropicClient is the repository pattern, full stop. Interface for testability, real impl for runtime.
src/schema/* (contract)DTOs / domain model / OpenAPI typesThe shape the system agrees on, with every layer built around itSame role: the single source of truth that downstream code consumes and upstream code produces.

Where the analogy buys us specific insights

Each of these is a “you already do this with web APIs” reframe of an LLM-specific design move. Gold for the blog:

LLM design moveSame move with a REST API
Define a tool with input_schema, force tool_choiceDefine an OpenAPI spec; the client must POST to the exact endpoint with the exact body
LLMOutputSchema.safeParse(tool_use.input), validate the responseresponseSchema.parse(await fetch(...)), never trust the response wire format. Zod here, class-validator/Pydantic/Joi there.
.strict() rejecting unknown keysDTOs that fail on extra fields, defense against silent API additions
Double validation (LLM output → merge → final)Validate the API response, attach server-owned fields (e.g. user ID from session), validate the merged object before persisting
Mock the AnthropicClient in testsMock the Stripe SDK in tests, exactly the same pattern
Inline error envelopes in the output arrayA REST response with [ { ok: true, data }, { ok: false, error } ], partial success isn’t an error
Prompt cachingHTTP caching, same idea, different layer
The eval scriptSynthetic monitoring (Datadog synthetics, k6), proves the third party still behaves

That last row is the framing that lands hardest: the live eval isn’t a test, it’s monitoring of a vendor.

But as everything, not all mental models are one-to-one. There are places that this thing breaks, and the following is what’s new with building with LLMs…not an exhaustive list, so please don’t cancel me. That’s Part II.

Part II goes deep on each of the four, with code and an eval run.

Code lives at github.com/KazChe/bug-cli-ai-agent.


Read next →
Why agentic apps don't slice like web apps
Why is this even hard to slice? Web app architecture rests on assumptions that agentic apps break on contact: durable state in a database, deterministic control flow in your runtime, loud failures, fr…