Consuming Descripex Libraries

Copy Markdown View Source

A guide for AI agents and developers who want to discover and call the API of a descripex-powered Elixir library — without reading source or parsing docs.

Why Descripex Libraries Are Worth Finding

Most Elixir libraries require you to read source code, parse docs, and guess at return shapes. A descripex-powered library tells you its entire API contract in structured data — params, types, defaults, errors, return shapes, composition hints — through three function calls.

No doc parsing. No source diving. No guessing.

MyLib.describe()                      # what modules exist?
MyLib.describe("funding")             # what functions does it have?
MyLib.describe("funding", :annualize) # tell me everything about this function

Every contract is compile-time validated — if the library compiles, the metadata aligns with actual function signatures and arities. You can trust the structure and focus your effort on behavior.

Detecting Descripex Support

A library uses descripex if any of these are true:

  • Code.ensure_loaded?(SomeModule) and function_exported?(SomeModule, :__api__, 0) — module-level introspection
  • Code.ensure_loaded?(MyLib) and function_exported?(MyLib, :describe, 0) — library-level discovery
  • A module's @moduledoc contains an "API Functions" table

Code.ensure_loaded?/1 first is not optional. function_exported?/3 inspects only modules that are already loaded, and under lazy loading it answers false for an annotated module nobody has called yet — a false negative that reads exactly like "this library does not use descripex".

When you detect it, use it. The structured metadata is always more reliable than parsing @doc strings.

Discovery Workflow

Three levels, each one step deeper. Start broad, drill into what you need.

Level 1: What Modules Exist?

MyLib.describe()
[%{
  module: MyLib.Funding,       # full module atom
  short_name: "funding",       # use this for Level 2/3 — shorter, friendlier
  namespace: "/funding",       # URL grouping hint (or nil)
  description: "...",          # @moduledoc text (or nil)
  function_count: 3,           # how many public API functions
  annotated?: true             # true = full contracts available
}]

annotated?: true means full contracts (params, returns, errors). false means only basic @doc/@spec info — still useful, just less structured.

short_name is a string, not an atom — it is a label derived from a module list you supply, so descripex never interns it. Feed it straight back into Level 2/3; the atom form (:funding) and the full module atom are accepted there too.

Level 2: What Functions Does It Have?

MyLib.describe("funding")
[%{
  name: :annualize,            # function name (atom)
  arity: 2,                    # max arity
  defaults: 1,                 # number of optional args — callable with 1 or 2 args
  description: "...",          # one-line description
  spec: "annualize(...) :: float()"  # typespec string (or nil)
}]

Scan this to find the function you need. Then drill in.

Level 3: Full Function Contract

MyLib.describe("funding", :annualize)

This is where it pays off — everything you need to call the function correctly:

%{
  name: :annualize,
  arity: 2,
  defaults: 1,
  description: "Annualize a per-period funding rate to APR.",
  spec: "annualize(number(), pos_integer()) :: float()",
  params: %{
    rate: %{kind: :value, description: "Per-period funding rate as decimal",
            schema: %{"type" => "number"}},        # JSON Schema, derived from @spec
    period_hours: %{kind: :value, default: 8, description: "Hours per period",
                    schema: %{"type" => "integer"}}
  },
  opts: %{                     # keyword options (or nil if none)
    precision: %{type: :integer, default: 2, description: "Decimal places",
                 schema: %{"type" => "integer"}}   # derived from the opt's type:
  },
  returns: %{type: :float, description: "Annualized percentage rate"},
  returns_example: 10.95,      # a concrete value you can expect back
  errors: [invalid_period: "Period must be > 0"],
  composes_with: [:normalize_rate]  # what to call next
}

You now know the param order, which are optional, what types to pass, what comes back, what can go wrong, and what to chain with. You can call this function correctly without reading a single line of source code.

Reading the Contract

params — Positional arguments. Pass them in order. Check kind:

  • :value — you provide this directly (a number, string, config)
  • :exchange_data — must be fetched from an external source first (the param may include a source hint telling you where to get it)

opts — Keyword options. Pass as last argument: annualize(rate, period, precision: 4).

schema — A JSON Schema map present on most params/opts, giving the wire type for JSON/MCP callers. Derived automatically from the function's @spec (params) or the opt's type: (opts), or from an explicit schema: declaration. Absent only for types a typespec can't express (e.g. term(), tuples). Elixir callers can ignore it and use kind/type/default.

defaults — Number of trailing params with defaults. If arity: 3, defaults: 1, you can call with 2 or 3 args.

errors — Known error cases the function may return or raise; treat these as contract hints and follow the library's actual return conventions.

composes_with — Other functions in the same module that chain well with this one. Follow the chain to build pipelines without guessing.

returns_example — A concrete value showing what the output looks like. Use this to understand the shape before you call.

Cookbook: From Zero Knowledge to a Correct Call

The sections above are a reference. This is the whole path end-to-end, in one sitting: you have a dependency in mix.exs, you know nothing else, and you need to make one correct call.

The walkthrough runs against descripex itself — the library dogfoods api(), so every output below is reproducible with nothing but mix deps.get and mix run. Substitute your own library's top-level module and the shape is identical.

Step 1 — Does it support descripex?

Code.ensure_loaded?(Descripex)
function_exported?(Descripex, :describe, 0)
# => true

Code.ensure_loaded?/1 first is not optional. function_exported?/3 only sees loaded modules, and in a fresh VM most of a dependency's modules are not loaded yet — so the check reports false for a library that fully supports descripex:

function_exported?(Descripex.Manifest, :__api__, 0)
# => false      # not loaded yet — this is a false negative
Code.ensure_loaded?(Descripex.Manifest)
function_exported?(Descripex.Manifest, :__api__, 0)
# => true

describe/0 exists → the library has a top-level entry point. Stop reading source.

Step 2 — What modules are in there?

Descripex.describe()
[
  %{
    module: Descripex.Manifest,
    description: "Introspects modules to build a JSON-serializable API manifest.\n\n...",  # full @moduledoc, elided here
    namespace: nil,
    annotated?: true,
    function_count: 1,
    short_name: "manifest"
  },
  %{
    module: Descripex.Describe,
    description: "Progressive disclosure for Descripex-powered libraries.\n\n...",
    namespace: nil,
    annotated?: true,
    function_count: 1,
    short_name: "describe"
  },
  %{
    module: Descripex.MCP,
    description: "Converts Descripex-annotated modules into MCP tool definitions.\n\n...",
    namespace: nil,
    annotated?: true,
    function_count: 1,
    short_name: "mcp"
  }
]

Three modules, all annotated?: true — full contracts available for all of them. Say the job is "expose this library's API to an MCP client": short_name: "mcp" is the obvious candidate. Carry that string forward.

Step 3 — What can that module do?

Descripex.describe("mcp")
[
  %{
    arity: 2,
    name: :tools,
    description: "Convert Descripex-annotated modules into MCP tool definitions.",
    spec: "tools(\n  [module()],\n  keyword()\n) :: [map()]",
    defaults: 1
  }
]

One function, tools/2, and defaults: 1 already tells you it is callable with one or two arguments. That is enough to pick it, not enough to call it. Drill in.

Step 4 — Get the full contract (Level 3)

Descripex.describe("mcp", :tools)
%{
  arity: 2,
  name: :tools,
  opts: nil,
  description: "Convert Descripex-annotated modules into MCP tool definitions.",
  spec: "tools(\n  [module()],\n  keyword()\n) :: [map()]",
  params: %{
    opts: %{
      default: [],
      description: "Options: name_style (:short | :full)",
      kind: :value
    },
    modules: %{
      description: "List of module atoms to convert",
      kind: :value,
      schema: %{"items" => %{"type" => "string"}, "type" => "array"}
    }
  },
  errors: nil,
  returns: %{
    type: :list,
    description: "List of MCP tool definition maps with name, description, and inputSchema"
  },
  defaults: 1,
  returns_example: [
    %{
      name: "funding__annualize",
      description: "Annualize a per-period funding rate.",
      inputSchema: %{type: "object", required: [], properties: %{}}
    }
  ],
  composes_with: nil
}

Everything needed to call it is in that map:

  • Both params are kind: :value — you supply them directly, nothing has to be fetched from an external source first.
  • Map key order is not argument order. params is a map; read positional order off spec (tools([module()], keyword())), not off the print order above.
  • modules has no default — required, first positional.
  • opts has default: [] — optional, second positional. Combined with arity: 2, defaults: 1, the legal call shapes are tools(modules) and tools(modules, opts).
  • opts here is a positional param, not the opts: section (opts: nil at the top level). So it is passed as an ordinary second argument, and its description names the one key it accepts: name_style (:short | :full).
  • returns.type: :list, and returns_example shows one element's shape: %{name:, description:, inputSchema:}.
  • errors: nil — no declared error contract; a plain list comes back.
  • modules carries a schema, opts does not. Schemas are derived from the function's own @spec at runtime: [module()] folds to a JSON Schema array of strings, while keyword() has no honest JSON Schema and is left unschema'd rather than given a guessed shape. A missing schema means "no machine-readable type available", never "any value goes" — fall back to description and spec. Descripex.typeless_params/1 lists every such param across a module list, tagged with why it went untyped.

Step 5 — Make the call

Descripex.MCP.tools([Descripex.Manifest])
[
  %{
    name: "manifest__build",
    description: "Build a complete API manifest from the given modules.",
    inputSchema: %{
      type: "object",
      required: ["modules"],
      properties: %{
        modules: %{"description" => "List of module atoms to introspect"}
      }
    }
  }
]

Correct on the first attempt, with the shape returns_example promised. The two-argument form works exactly as the contract described:

Descripex.MCP.tools([Descripex.Manifest], name_style: :full)
# => [%{name: "descripex__manifest__build", description: "...", inputSchema: %{...}}, ...]

Note returns_example is an illustration of the shape, not a recorded output — its "funding__annualize" comes from the doc example, not from this call. Trust it for structure, not for values.

Five calls, no source reading, no doc parsing, no guessing.

Combining Manual @doc with api()

api() generates both human-readable @doc text and machine-readable @doc hints: metadata. These live in separate slots in Elixir's compiled BEAM docs (element 4 and element 5 of the docs tuple) — they never collide.

This means you can write a manual @doc after api() to provide custom prose while keeping the structured metadata:

# api() writes hints metadata (slot 5) AND generated @doc text (slot 4)
api(:imbalance!, "Calculate orderbook bid/ask imbalance (raises on error).",
  params: [
    orderbook: [kind: :exchange_data, description: "Orderbook data"],
    depth: [kind: :value, default: 10, description: "Depth levels"]
  ],
  returns: %{type: :float, description: "Imbalance ratio"}
)

# Manual @doc AFTER api() — overwrites only slot 4 (prose), hints in slot 5 survive
@doc "Bang variant of `imbalance/2`. Returns the float directly or raises on error."
@spec imbalance!(map(), pos_integer()) :: float()
def imbalance!(orderbook, depth \\ 10), do: ...

Result: the function gets both the rich human-friendly @doc text AND the full machine-readable hints contract. Best of both worlds.

Important: The manual @doc must come after api() — Elixir uses last-wins for @doc text. If placed before, api()'s generated text overwrites it.

Alternative Entry Points

Direct Module Introspection

When you know the exact module, skip the top-level and go direct:

MyLib.Funding.__api__()
# => [%{name: :annualize, arity: 2, defaults: 1, spec: "...", hints: %{...}}, ...]

MyLib.Funding.__api__(:annualize)
# => %{name: :annualize, arity: 2, defaults: 1, spec: "...", hints: %{...}}

The hints map has the same fields as Level 3 (params, opts, returns, returns_example, errors, composes_with, description).

__api__/0 is runtime-enriched; the BEAM doc chunk is not. __api__/0 fills hints.params.<name>.schema / hints.opts.<name>.schema from the function's @spec and declared type: at runtime. The compile-time doc chunk (Code.fetch_docs/1meta[:hints]) is the raw declared surface and carries no spec-derived schemas, so the two diverge on :schema. This is intentional. If you cross-check the two surfaces for equality (e.g. to detect api() misattachment), normalize both with Descripex.normalize_for_doc_compare/1 first — it strips every :schema key so the comparison doesn't false-positive on the injected schema:

Descripex.normalize_for_doc_compare(MyLib.Funding.__api__(:annualize).hints) ==
  Descripex.normalize_for_doc_compare(meta_hints)

Get the Module List

MyLib.__descripex_modules__()
# => [MyLib.Funding, MyLib.Risk]

Without a Top-Level Discoverable

Use Descripex.Describe directly — same three levels, but you pass the module list:

Descripex.Describe.describe([MyLib.Funding, MyLib.Risk])
Descripex.Describe.describe([MyLib.Funding, MyLib.Risk], "funding")
Descripex.Describe.describe([MyLib.Funding, MyLib.Risk], "funding", :annualize)

Manifest (Batch/Offline)

Grab the entire library's API as a JSON-serializable map:

Descripex.Manifest.build([MyLib.Funding, MyLib.Risk])
%{
  version: "1.0",
  generated_at: "2025-01-01T00:00:00Z",
  modules: [%{
    module: "MyLib.Funding",     # strings in manifest (JSON-friendly)
    namespace: "/funding",
    description: "...",
    functions: [%{
      name: "annualize",        # strings (not atoms)
      arity: 2,
      defaults: 1,
      signature: "annualize(rate, period_hours)",
      description: "...",
      spec: "annualize(number(), pos_integer()) :: float()",
      hints: %{...}             # same shape as __api__ hints
    }]
  }]
}

Note: Manifest uses strings for module/function names. __api__ and describe use atoms.

For an offline / batch export to a file (CI, agent toolchains), use the Mix task instead of calling Manifest.build/1 yourself:

mix descripex.manifest MyLib.Funding MyLib.Risk   # writes api_manifest.json
mix descripex.manifest --app my_app               # auto-discover annotated modules in an app
mix descripex.manifest --pretty -o tools.json MyLib.Funding

MCP Tool Definitions

If you host the library behind the Model Context Protocol, turn its annotated modules straight into MCP tool definitions — no hand-written schemas:

Descripex.MCP.tools([MyLib.Funding, MyLib.Risk])
# => [%{
#   name: "funding__annualize",          # "<short_module>__<function>"
#   description: "Annualize a per-period funding rate to APR.",
#   inputSchema: %{type: "object", properties: %{...}, required: [...]}
# }, ...]

inputSchema is a JSON Schema assembled from the function's params and opts (the same schemas you see at Level 3). Pass name_style: :full for fully-qualified tool names (my_lib_funding__annualize). Functions without api() annotations are skipped.

Quick Reference

Want to...Call
List all modulesMyLib.describe()
List functions in a moduleMyLib.describe("funding")
Get full function contractMyLib.describe("funding", :annualize)
Get module listMyLib.__descripex_modules__()
Introspect one module directlyMyLib.Funding.__api__()
Introspect one function directlyMyLib.Funding.__api__(:annualize)
Get everything as JSON-ready mapDescripex.Manifest.build(modules)
Export manifest to diskmix descripex.manifest MyLib.Funding
Get MCP tool definitionsDescripex.MCP.tools(modules)
Detect descripex supportCode.ensure_loaded?(Mod) and function_exported?(Mod, :__api__, 0)