ClaudeWrapper.Structured behaviour (ClaudeWrapper v0.12.0)

Copy Markdown View Source

Run a Claude call as a typed function.

A Structured task turns structured input into a rendered prompt, constrains the model's output to a JSON Schema, and parses the validated object into a domain value -- returning the raw ClaudeWrapper.Result.t/0 alongside as an audit log. The model call is the only stochastic step; prompt construction, output shape, and parsing are deterministic and typed -- a deterministic envelope around a stochastic core.

A task module implements:

  • render/1 -- input -> a ClaudeWrapper.Prompt.t/0 or a string
  • schema/0 -- the JSON Schema the output must conform to (claude's --json-schema); the validated object arrives as the result's structured_output
  • parse/1 -- optional; map the validated object into a domain value. Defaults to returning the object unchanged.

run/3 wires them together.

Example

defmodule ExtractName do
  @behaviour ClaudeWrapper.Structured

  @impl true
  def render(text), do: "Extract the person's name from: " <> text

  @impl true
  def schema do
    %{
      "type" => "object",
      "properties" => %{"name" => %{"type" => "string"}},
      "required" => ["name"]
    }
  end
end

{:ok, %{"name" => name}, %ClaudeWrapper.Result{}} =
  ClaudeWrapper.Structured.run(ExtractName, "Hi, I'm Ada Lovelace.")

Parsing into a domain value

An optional parse/1 maps the validated object into whatever shape the caller wants -- a struct, an Ecto schema, a normalized tuple. The model still only returns the schema-validated object; parse/1 is the deterministic last step.

defmodule ExtractPerson do
  @behaviour ClaudeWrapper.Structured

  @impl true
  def render(text), do: "Extract the person's full name from: " <> text

  @impl true
  def schema, do: ExtractName.schema()

  @impl true
  def parse(%{"name" => name}), do: {:ok, %Person{name: name}}
  def parse(other), do: {:error, {:unexpected_shape, other}}
end

{:ok, %Person{name: "Ada Lovelace"}, %ClaudeWrapper.Result{}} =
  ClaudeWrapper.Structured.run(ExtractPerson, "Hi, I'm Ada Lovelace.")

Reasoning room

A hard schema gives the model no space to think before answering. If a task needs reasoning, add a field for it to the schema (e.g. a "reasoning" string beside the "answer" object) rather than dropping the schema -- you keep a guaranteed-parseable object and thinking room in one deterministic call.

Summary

Callbacks

Map the schema-validated object into a domain value. Optional; the default returns the object unchanged.

Turn the task input into a prompt (a Prompt struct or a string).

The JSON Schema the model output must conform to.

Functions

Run module over input.

Callbacks

parse(structured_output)

(optional)
@callback parse(structured_output :: map() | list()) :: {:ok, term()} | {:error, term()}

Map the schema-validated object into a domain value. Optional; the default returns the object unchanged.

def parse(%{"name" => name}), do: {:ok, %Person{name: name}}

render(input)

@callback render(input :: term()) :: ClaudeWrapper.Prompt.t() | String.t()

Turn the task input into a prompt (a Prompt struct or a string).

schema()

@callback schema() :: map()

The JSON Schema the model output must conform to.

Functions

run(module, input, opts \\ [])

@spec run(module(), term(), keyword()) ::
  {:ok, term(), ClaudeWrapper.Result.t()} | {:error, term()}

Run module over input.

Returns {:ok, parsed, result} -- the parsed domain value plus the raw ClaudeWrapper.Result.t/0 (the audit log) -- or {:error, reason}.

opts are forwarded to ClaudeWrapper.query/2 (e.g. :model, :working_dir, :timeout); :json_schema is set from schema/0.

Error reasons:

  • a ClaudeWrapper.Error -- render/1 returned a non-prompt (:invalid_render), the prompt failed to render, the CLI call failed, or the result carried no structured_output (:no_structured_output)
  • a Jason encode error -- schema/0 was not JSON-encodable
  • whatever parse/1 returned on {:error, _}