Elixir and the BEAM for AI systems

Validating LLM Tool Call Arguments With Ecto

Ecto schemaless changesets as the pre-execution trust boundary on LLM tool-call arguments, not another output-shape validator.

TL;DR: Structured LLM output asks whether the model’s response is shaped correctly. This post asks a different question: should the action the model just requested actually run? A schemaless Ecto.Changeset is the right tool for tool-call arguments because they arrive as a plain map with a known shape per tool, and you don’t want a schema module per tool. But shape validation is only the first of three tiers — type, then range/enum, then authorization — and the third one is where a well-formed, in-range argument still points at someone else’s row. JSON Schema and the MCP 2026-07-28 spec’s composition support can’t do that tier for you, no matter how expressive the schema gets.

The output-parsing post and this one are asking different questions

Structured LLM output in Elixir is a library scorecard for one direction: the model produced text, and you need it cast into a struct your database can use — InstructorLite, ReqLLM, or a hand-rolled changeset loop. That post’s whole argument is downstream of one assumption: the output arrived, and now you’re parsing it.

This post is the other direction. Before a tool-calling model’s chosen function ever executes, it hands you a JSON object of arguments — %{"invoice_id" => "inv_88", "amount_cents" => 4500, "reason" => "goodwill"} — and you have to decide whether to run refund_invoice/1 with that map, or reject the call. That decision happens before execution, not after a response lands, and it isn’t a parsing problem. It’s an admission-control problem, structurally closer to validating a webhook payload than to casting a completion into a struct. Same Ecto primitive both posts reach for — a changeset — pointed at a different question.

The model is an untrusted input source the moment it picks arguments

Here’s the part that’s easy to lose once tool calling feels routine: the arguments a model hands your tool aren’t a fixed payload you control. They’re a choice the model made, informed by a system prompt, the conversation so far, and whatever the model inferred it should do next — including inferences a prompt injection buried in a tool result put there. The moment a function call can touch a database row, a filesystem path, or a payment, “the model asked for this” stops being a reason to trust the argument and starts being the reason to check it.

That’s a trust-boundary framing, not a security-theater one. A web form is untrusted input because a human can type anything into it. A tool call is untrusted input for the same reason, plus one more: the “human” here is a language model whose job is literally to produce plausible-looking structured data, which means a malformed or malicious argument from an LLM is more likely to look well-formed than a typo from a person fat-fingering a form field. The parsing layer that catches a human’s typo won’t catch a model’s confidently wrong tenant_id.

A well-formed argument and a safe argument are different properties, and only one of them is a parsing problem.

Why a schemaless changeset fits a tool call

Ecto’s normal changeset flow assumes a schema module — a %User{}, a %Order{}, something with fields defined at compile time and usually a database table behind it. Tool arguments don’t have that. Every tool your agent exposes has its own argument shape, and writing a full Ecto.Schema module per tool for data you’re never going to persist is exactly the kind of scaffolding-for-later that doesn’t earn its keep.

The schemaless form solves this directly. Pass a {data, types} tuple instead of a struct, and cast/4 works the same way:

defmodule RefundArgs do
  @types %{
    invoice_id: :string,
    amount_cents: :integer,
    reason: :string
  }

  def changeset(params) do
    {%{}, @types}
    |> Ecto.Changeset.cast(
         params, Map.keys(@types)
       )
    |> Ecto.Changeset.validate_required(
         [:invoice_id, :amount_cents, :reason]
       )
  end
end

cast/4 here (three required args plus the implicit options) takes the empty map as your “data,” the @types map as the field/type contract, params as whatever the model sent, and the list of permitted keys. Anything not in that list gets silently dropped — which is itself doing security work, not just tidiness: a model that hallucinates an extra key like "admin" => true never makes it into the changeset at all, because cast/4 only pulls fields you explicitly permitted. That’s the same allow-list instinct behind casting only permitted params out of a Phoenix form submission, applied one layer earlier — to the arguments the model is proposing before any handler runs.

One tool, one module, no macro, no table. If you’ve got a dozen tools, that’s a dozen small modules like this one — each a few lines, each independently testable, none of them coupled to anything you have to persist.

Three tiers of validation, and only two of them are a parsing problem

Getting cast/4 to accept a map is tier one — type and shape. It’s necessary and it’s also the least interesting part of this problem, because it’s exactly what any JSON Schema validator does too. Tier two is range and enum, still inside Ecto.Changeset’s wheelhouse:

def changeset(params) do
  {%{}, @types}
  |> Ecto.Changeset.cast(
       params, Map.keys(@types)
     )
  |> Ecto.Changeset.validate_required(
       [:invoice_id, :amount_cents, :reason]
     )
  |> Ecto.Changeset.validate_number(
       :amount_cents,
       greater_than: 0,
       less_than_or_equal_to: 50_000
     )
  |> Ecto.Changeset.validate_inclusion(
       :reason,
       ["duplicate", "error", "goodwill"]
     )
end

That’s still just a smarter parser. A refund_invoice call for $45 on a real invoice ID with a valid reason string passes both tiers cleanly — and it should still get rejected if inv_88 doesn’t belong to the tenant whose conversation triggered the call. That’s tier three, and it’s the one a schema — Ecto’s or JSON Schema’s — structurally cannot express, because “does this row belong to this caller” isn’t a property of the argument. It’s a property of the argument and a database lookup and the caller’s identity, three things no static schema has access to at once.

Say the tool’s authorization check lives in its own function, run only after the changeset is valid:

def authorize(changeset, tenant_id) do
  with {:ok, args} <-
         Ecto.Changeset.apply_action(
           changeset, :insert
         ),
       %Invoice{} = inv <-
         Invoices.get_for_tenant(
           tenant_id, args.invoice_id
         ) do
    {:ok, args}
  else
    nil -> {:error, :not_found}
    error -> error
  end
end

apply_action/2 only returns {:ok, struct} when changeset.valid? is true — an invalid changeset comes back as {:error, changeset} and the with short-circuits, so tier three never runs against arguments that failed tier one or two. Invoices.get/1 and the Invoice struct here are illustrative of your own app’s lookup, not a specific library call — swap in whatever your context module actually does.

This is where it meets tenant isolation from the other side: that post is isolation at the data layer, this is authorization on one individual call. It’s the tier worth the most attention in a multi-tenant product, because it’s the one where a rejection isn’t a UX nicety, it’s the entire security boundary. If you’re building the kind of AI product where a tool call can touch another tenant’s data, this is the exact design conversation I have with fractional CTO clients before it ships, not after a pen test finds it — the authorization tier is cheap to add up front and expensive to retrofit once a dozen tools already skip it.

Can a changeset express what JSON Schema’s oneOf expresses?

Not natively, and it’s worth being honest about the gap rather than pretending it away. The MCP 2026-07-28 specification — the current protocol version — has tool inputSchema on full JSON Schema 2020-12: schemas can now use oneOf, anyOf, allOf composition and conditionals, on top of the type: "object" root constraint MCP already required. That’s a real feature — a tool like refund_invoice that accepts either a duplicate reason (no extra fields) or a goodwill reason (requires an approver_id) is naturally expressed as oneOf two sub-schemas. Ecto.Changeset has no built-in equivalent of that composition.

What it does have is pattern matching on the function head, which gets you the same outcome by a different route — you write the branch explicitly instead of declaring it:

@types %{
  invoice_id: :string,
  amount_cents: :integer,
  reason: :string,
  approver_id: :string
}

# Shared tiers. Note approver_id is a known
# TYPE but is not cast here — the base call
# does not permit it.
defp base_changeset(params) do
  {%{}, @types}
  |> Ecto.Changeset.cast(
       params,
       [:invoice_id, :amount_cents, :reason]
     )
  |> Ecto.Changeset.validate_required(
       [:invoice_id, :amount_cents, :reason]
     )
end

def changeset(
      %{"reason" => "goodwill"} = params
    ) do
  base_changeset(params)
  |> Ecto.Changeset.cast(
       params, [:approver_id]
     )
  |> Ecto.Changeset.validate_required(
       [:approver_id]
     )
end

def changeset(params), do: base_changeset(params)

The split matters more than it looks. approver_id lives in @types so the goodwill branch can cast it, but the base clause doesn’t permit it — so on any other branch, an approver_id the model slipped in is dropped rather than accepted. That’s the allow-list from two sections up paying off a second time, this time as an authorization control rather than tidiness.

This punts, and I want to name the punt precisely: you’re hand-writing the discriminated union that oneOf declares in one place. It works, it’s testable, and for a handful of tools with a handful of variants it’s genuinely fine — the same “you can write fifty lines instead of taking a dependency” argument the structured-output post makes about the parsing side applies here too. Where it stops being fine is a tool surface large enough, or a schema composed deeply enough, that the pattern-match branches start duplicating logic across variants faster than you can keep them in sync. At that point you’re not avoiding a JSON Schema validator, you’re reimplementing a worse one by hand — that’s the point to actually evaluate a library that walks the composed schema for you, not a claim that the boundary needs one today.

Bounding the depth of what you’re willing to walk

The same spec adds two controls worth separating, because they carry different force. Dereferencing is a hard rule: implementations MUST NOT automatically dereference $ref values that resolve to a network URI. Bounding is a recommendation: implementations SHOULD apply “a maximum schema depth, a cap on the total number of subschemas, or a per-validation time budget.”

Note what that bound is aimed at — the spec is defending the validator against a malicious schema. The version that bites you is the one it doesn’t cover: a malicious argument. Nothing stops a compromised or adversarially prompted model from nesting a tool argument fifty maps deep and handing your cast/4 call a structure that costs real CPU to even reject. Be clear about what the guard below does and doesn’t cover: it bounds depth, not breadth. A flat map with three hundred thousand keys sails through it. If your HTTP layer doesn’t already cap request body size, cap node count too — the spec names all three bounds for a reason. Same reasoning, different attacker input, and it’s your inference to act on rather than the spec’s requirement.

Ecto.Changeset doesn’t walk nested structure recursively on your behalf, so the bound has to be your own, ahead of the changeset:

defmodule ToolArgs.Depth do
  @max_depth 8

  def check(value, depth \\ 0)

  def check(_v, depth)
      when depth > @max_depth,
      do: {:error, :too_deep}

  def check(map, depth)
      when is_map(map) do
    walk(Map.values(map), depth)
  end

  def check(list, depth)
      when is_list(list) do
    walk(list, depth)
  end

  def check(_scalar, _depth), do: {:ok, :ok}

  defp walk(values, depth) do
    Enum.reduce_while(
      values, {:ok, :ok},
      fn v, acc ->
        case check(v, depth + 1) do
          {:ok, _} -> {:cont, acc}
          err -> {:halt, err}
        end
      end
    )
  end
end

Run this before cast/4, not after — the whole point is to reject the pathological argument before you spend cycles walking it through a changeset. It’s a small function, it’s the kind of thing that never shows up in a demo, and it’s exactly the tier a stack that only validates shape and range will quietly skip.

Should you tell the model why its call was rejected?

Sometimes — but returning your validation error verbatim isn’t the free win it looks like, because a model that gets fed your rejection reason learns the shape of your guard, not just that this one call failed. Both InstructorLite and the raw retry loop in the structured-output post feed changeset errors back to the model on purpose, because for a parsing failure the retry usually converges: “your JSON didn’t have a required field” is information the model can act on correctly next time, and a couple of retries against your own schema costs you a call, not a security incident.

An authorization rejection is a different kind of failure, and treating it the same way is the mistake to watch for. If refund_invoice on inv_88 gets denied because it belongs to another tenant, and your loop re-prompts with “that invoice doesn’t belong to this account, try again,” you’ve handed a probing agent — or the injected instruction steering it — a working oracle for enumerating which invoice IDs do belong to the current tenant, one denied guess at a time. The fix isn’t silence; it’s a flatter, generic denial for tier-three failures — “that action isn’t permitted” with no specifics — while tier-one and tier-two failures keep the specific, retryable message that actually helps the model self-correct. Log the specific reason server-side, where a human reviews it later; don’t hand it back into the context the model is reasoning from next. That log is the same artifact an agent evidence pack is built to collect after the fact — this post is the check that runs before the tool executes, that post is what a reviewer asks for once it already has.

Where this fits next to the rest of the stack

None of this replaces an allow-list of which tools an agent can call in the first place, and it isn’t a substitute for the transport-level auth an MCP server built on Phoenix already needs before a tool call reaches your code at all. It’s the layer in between: the tool is allowed, the caller is authenticated, and you still have to decide — argument by argument — whether this specific call, with these specific values, should run. A JSON Schema validator, however expressive MCP’s spec makes it, checks the shape of the request. Only your own authorization tier checks whether the request belongs to the person asking for it.

If you’re standing up tool-calling agents against real customer data and want a second set of eyes on where the authorization boundary actually needs to sit, that’s a conversation worth having before the first tool ships, not after an agent finds the gap for you.